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

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

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

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

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

# 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

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