strat-gameplay-webapp/backend/app/core/CLAUDE.md
Cal Corum 0b6076d5b8 CLAUDE: Implement Phase 3B - X-Check league config tables
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>
2025-11-01 19:50:55 -05:00

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:

  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:

# 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 movement
  • AdvancementResult: 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:

  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):

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:

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

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 fields
  • AbRoll: At-bat roll (1d6 + 2d6 + 2d20)
  • JumpRoll: Baserunning roll
  • FieldingRoll: Defensive play roll
  • D20Roll: 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:

  1. Game was evicted from memory (idle timeout)
  2. Game never created in state manager
  3. 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, 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