Complete X-Check resolution table system for defensive play outcomes. Components: - Defense range tables (20×5) for infield, outfield, catcher - Error charts for LF/RF and CF (ratings 0-25) - Placeholder error charts for P, C, 1B, 2B, 3B, SS (awaiting data) - get_fielders_holding_runners() - Complete implementation - get_error_chart_for_position() - Maps all 9 positions - 6 X-Check placeholder advancement functions (g1-g3, f1-f3) League Config Integration: - Both SbaConfig and PdConfig include X-Check tables - Shared common tables via league_configs.py - Attributes: x_check_defense_tables, x_check_error_charts, x_check_holding_runners Testing: - 36 tests for X-Check tables (all passing) - 9 tests for X-Check placeholders (all passing) - Total: 45/45 tests passing Documentation: - Updated backend/CLAUDE.md with Phase 3B section - Updated app/config/CLAUDE.md with X-Check tables documentation - Updated app/core/CLAUDE.md with X-Check placeholder functions - Updated tests/CLAUDE.md with new test counts (519 unit tests) - Updated phase-3b-league-config-tables.md (marked complete) - Updated NEXT_SESSION.md with Phase 3B completion What's Pending: - 6 infield error charts need actual data (P, C, 1B, 2B, 3B, SS) - Phase 3C will implement full X-Check resolution logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
38 KiB
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:
_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:
# 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):
# 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:
# 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:
- Advances batting order index (with wraparound)
- Fetches/caches active lineups
- Sets snapshot fields: current_batter/pitcher/catcher_lineup_id
- Calculates on_base_code bit field
- 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 logicPlayResult: Complete outcome with statistics
Initialization:
# 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:
# 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:
@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 movementAdvancementResult: Complete result with all movements
Groundball Result Types (1-13):
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:
runner_advancement = RunnerAdvancement()
result = runner_advancement.advance_runners(
outcome=PlayOutcome.GROUNDBALL_A,
hit_location='SS',
state=state,
defensive_decision=defensive_decision
)
# Result contains:
result.movements # List[RunnerMovement]
result.outs_recorded # int
result.runs_scored # int
result.result_type # GroundballResultType
result.description # str
Chart Selection Logic:
Special case: 2 outs → Result 1 (batter out, runners hold)
Infield In (runner on 3rd scenarios):
- Applies when: defensive_decision.infield_depth == "infield_in"
- Affects bases: 3, 5, 6, 7 (any scenario with runner on 3rd)
- Uses: _apply_infield_in_chart()
Corners In (hybrid approach):
- Applies when: defensive_decision.infield_depth == "corners_in"
- Only for corner hits: P, C, 1B, 3B
- Middle infield (2B, SS) uses Infield Back
Infield Back (default):
- Normal defensive positioning
- Uses: _apply_infield_back_chart()
Double Play Mechanics:
- Base probability: 45%
- Positioning modifiers: infield_in -15%
- Hit location modifiers: up middle +10%, corners -10%
- TODO: Runner speed modifiers when ratings available
DECIDE Opportunity (Result 12):
- Lead runner can attempt to advance
- Offense chooses whether to attempt
- Defense responds (take sure out at 1st OR throw to lead runner)
- Currently simplified (conservative default)
- TODO: Interactive decision-making via WebSocket
Flyball Types (Direct Mapping):
Flyballs use direct outcome-to-behavior mapping (no chart needed):
| Outcome | Depth | R3 | R2 | R1 | Notes |
|---|---|---|---|---|---|
| FLYOUT_A | Deep | Advances (scores) | Advances to 3rd | Advances to 2nd | All runners tag up |
| FLYOUT_B | Medium | Scores | DECIDE (defaults hold) | Holds | Sac fly + DECIDE |
| FLYOUT_BQ | Medium-shallow | DECIDE (defaults hold) | Holds | Holds | fly(b)? from cards |
| FLYOUT_C | Shallow | Holds | Holds | Holds | Too shallow to tag |
Usage:
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:
- No Chart Lookup: Direct mapping from outcome to behavior
- No Result Type:
result_typeisNonefor flyballs (groundballs use 1-13) - 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)
- 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)→ AdvancementResultx_check_g2(on_base_code, defender_in, error_result)→ AdvancementResultx_check_g3(on_base_code, defender_in, error_result)→ AdvancementResultx_check_f1(on_base_code, error_result)→ AdvancementResultx_check_f2(on_base_code, error_result)→ AdvancementResultx_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 inerror_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):
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 attemptFieldingRoll: Defensive play (1d20 + 3d6 + 1d100)D20Roll: Generic d20
Usage:
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:
# 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
secretsmodule (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 methodsValidationError: Custom exception for rule violations
Common Validations:
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:
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 fieldsAbRoll: At-bat roll (1d6 + 2d6 + 2d20)JumpRoll: Baserunning rollFieldingRoll: Defensive play rollD20Roll: Generic d20 roll
AbRoll Structure:
@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):
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:
# 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:
# 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:
# 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:
# 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:
# ✅ 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
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
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
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)
# 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
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)
# 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)
# 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
# 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
# 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
# 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
# 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:
- Game was evicted from memory (idle timeout)
- Game never created in state manager
- Server restarted
Solutions:
# 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).
# 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:
# 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:
# 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:
# 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:
# 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
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
# 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
# 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:
# 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
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
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
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, recoverygame_engine.py- Orchestration, workflow, persistenceplay_resolver.py- Outcome-first resolution (manual + auto)runner_advancement.py- Groundball (13 result types) & flyball (4 types) advancementdice.py- Cryptographic dice rolling systemvalidators.py- Rule enforcementai_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