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>
1372 lines
38 KiB
Markdown
1372 lines
38 KiB
Markdown
# Core - Game Engine & Logic
|
|
|
|
## Purpose
|
|
|
|
The `core` directory contains the heart of the baseball simulation engine. It orchestrates complete gameplay flow from dice rolls to play resolution, managing in-memory game state for fast real-time performance (<500ms response target).
|
|
|
|
**Key Responsibilities**:
|
|
- In-memory state management (StateManager)
|
|
- Game orchestration and workflow (GameEngine)
|
|
- Cryptographic dice rolling (DiceSystem)
|
|
- Play outcome resolution (PlayResolver)
|
|
- Runner advancement logic (RunnerAdvancement)
|
|
- Rule validation (GameValidator)
|
|
- AI opponent decision-making (AIOpponent)
|
|
|
|
## Architecture Overview
|
|
|
|
### Data Flow
|
|
|
|
```
|
|
Player Action (WebSocket)
|
|
↓
|
|
GameEngine.submit_defensive_decision()
|
|
GameEngine.submit_offensive_decision()
|
|
↓
|
|
GameEngine.resolve_play()
|
|
↓
|
|
DiceSystem.roll_ab() → AbRoll (1d6 + 2d6 + 2d20)
|
|
↓
|
|
PlayResolver.resolve_outcome()
|
|
├─ Manual mode: Use player-submitted outcome
|
|
└─ Auto mode: Generate from PdPlayer ratings
|
|
↓
|
|
RunnerAdvancement.advance_runners() (for groundballs)
|
|
├─ Determine result (1-13) based on situation
|
|
├─ Execute result (movements, outs, runs)
|
|
└─ Return AdvancementResult
|
|
↓
|
|
GameEngine._apply_play_result() → Update state
|
|
↓
|
|
GameEngine._save_play_to_db() → Persist play
|
|
↓
|
|
StateManager.update_state() → Cache updated state
|
|
↓
|
|
WebSocket broadcast → All clients
|
|
```
|
|
|
|
### Component Relationships
|
|
|
|
```
|
|
StateManager (singleton)
|
|
├─ Stores: Dict[UUID, GameState]
|
|
├─ Stores: Dict[UUID, Dict[int, TeamLineupState]]
|
|
└─ Provides: O(1) lookups, recovery from DB
|
|
|
|
GameEngine (singleton)
|
|
├─ Uses: StateManager for state
|
|
├─ Uses: PlayResolver for outcomes
|
|
├─ Uses: RunnerAdvancement (via PlayResolver)
|
|
├─ Uses: DiceSystem for rolls
|
|
├─ Uses: GameValidator for rules
|
|
├─ Uses: AIOpponent for AI decisions
|
|
└─ Uses: DatabaseOperations for persistence
|
|
|
|
PlayResolver
|
|
├─ Uses: DiceSystem for rolls
|
|
├─ Uses: RunnerAdvancement for groundballs
|
|
├─ Uses: PdAutoResultChart (auto mode only)
|
|
└─ Returns: PlayResult
|
|
|
|
RunnerAdvancement
|
|
├─ Uses: GameState for context
|
|
├─ Uses: DefensiveDecision for positioning
|
|
└─ Returns: AdvancementResult with movements
|
|
|
|
DiceSystem (singleton)
|
|
├─ Uses: secrets module (cryptographic)
|
|
├─ Maintains: roll_history for auditing
|
|
└─ Returns: Typed roll objects (AbRoll, JumpRoll, etc.)
|
|
```
|
|
|
|
## Core Modules
|
|
|
|
### 1. state_manager.py
|
|
|
|
**Purpose**: In-memory game state management with O(1) lookups
|
|
|
|
**Key Classes**:
|
|
- `StateManager`: Singleton managing all active game states
|
|
|
|
**Storage**:
|
|
```python
|
|
_states: Dict[UUID, GameState] # O(1) state lookup
|
|
_lineups: Dict[UUID, Dict[int, TeamLineupState]] # Cached lineups
|
|
_last_access: Dict[UUID, pendulum.DateTime] # For eviction
|
|
_pending_decisions: Dict[tuple, asyncio.Future] # Phase 3 async decisions
|
|
```
|
|
|
|
**Common Methods**:
|
|
```python
|
|
# Create new game
|
|
state = await state_manager.create_game(game_id, league_id, home_id, away_id)
|
|
|
|
# Get state (fast O(1))
|
|
state = state_manager.get_state(game_id)
|
|
|
|
# Update state
|
|
state_manager.update_state(game_id, state)
|
|
|
|
# Lineup caching
|
|
state_manager.set_lineup(game_id, team_id, lineup_state)
|
|
lineup = state_manager.get_lineup(game_id, team_id)
|
|
|
|
# Recovery from database
|
|
state = await state_manager.recover_game(game_id)
|
|
|
|
# Memory management
|
|
evicted_count = state_manager.evict_idle_games(idle_minutes=60)
|
|
```
|
|
|
|
**Performance**:
|
|
- State access: O(1) dictionary lookup
|
|
- Memory per game: ~1-2KB (without player data)
|
|
- Evicts idle games after configurable timeout
|
|
|
|
**Recovery Strategy**:
|
|
- Uses last completed play to rebuild runner positions
|
|
- Extracts on_first_final, on_second_final, on_third_final, batter_final
|
|
- Reconstructs batter indices (corrected by _prepare_next_play)
|
|
- No play replay needed (efficient recovery)
|
|
|
|
---
|
|
|
|
### 2. game_engine.py
|
|
|
|
**Purpose**: Main orchestrator coordinating complete gameplay workflow
|
|
|
|
**Key Classes**:
|
|
- `GameEngine`: Singleton handling game lifecycle and play resolution
|
|
|
|
**Core Workflow** (resolve_play):
|
|
```python
|
|
# STEP 1: Resolve play with dice rolls
|
|
result = resolver.resolve_outcome(outcome, hit_location, state, decisions, ab_roll)
|
|
|
|
# STEP 2: Save play to DB (uses snapshot from GameState)
|
|
await self._save_play_to_db(state, result)
|
|
|
|
# STEP 3: Apply result to state (outs, score, runners)
|
|
self._apply_play_result(state, result)
|
|
|
|
# STEP 4: Update game state in DB (only if changed)
|
|
if state_changed:
|
|
await self.db_ops.update_game_state(...)
|
|
|
|
# STEP 5: Check for inning change
|
|
if state.outs >= 3:
|
|
await self._advance_inning(state, game_id)
|
|
|
|
# STEP 6: Prepare next play (always last step)
|
|
await self._prepare_next_play(state)
|
|
```
|
|
|
|
**Key Methods**:
|
|
|
|
```python
|
|
# Game lifecycle
|
|
await game_engine.start_game(game_id)
|
|
await game_engine.end_game(game_id)
|
|
|
|
# Decision submission
|
|
await game_engine.submit_defensive_decision(game_id, decision)
|
|
await game_engine.submit_offensive_decision(game_id, decision)
|
|
|
|
# Play resolution
|
|
result = await game_engine.resolve_play(game_id, forced_outcome=None)
|
|
result = await game_engine.resolve_manual_play(game_id, ab_roll, outcome, hit_location)
|
|
|
|
# Phase 3: Async decision awaiting
|
|
defensive_dec = await game_engine.await_defensive_decision(state, timeout=30)
|
|
offensive_dec = await game_engine.await_offensive_decision(state, timeout=30)
|
|
|
|
# State management
|
|
state = await game_engine.get_game_state(game_id)
|
|
state = await game_engine.rollback_plays(game_id, num_plays)
|
|
```
|
|
|
|
**_prepare_next_play()**: Critical method that:
|
|
1. Advances batting order index (with wraparound)
|
|
2. Fetches/caches active lineups
|
|
3. Sets snapshot fields: current_batter/pitcher/catcher_lineup_id
|
|
4. Calculates on_base_code bit field
|
|
5. Used by _save_play_to_db() for Play record
|
|
|
|
**Optimization Notes**:
|
|
- Lineup caching eliminates redundant DB queries
|
|
- Conditional state updates (only when score/inning/status changes)
|
|
- Batch roll saving at inning boundaries
|
|
- 60% query reduction vs naive implementation
|
|
|
|
---
|
|
|
|
### 3. play_resolver.py
|
|
|
|
**Purpose**: Resolves play outcomes based on dice rolls and game state
|
|
|
|
**Architecture**: Outcome-first design
|
|
- **Manual mode** (primary): Players submit outcomes after reading physical cards
|
|
- **Auto mode** (rare): System generates outcomes from digitized ratings (PD only)
|
|
|
|
**Key Classes**:
|
|
- `PlayResolver`: Core resolution logic
|
|
- `PlayResult`: Complete outcome with statistics
|
|
|
|
**Initialization**:
|
|
```python
|
|
# Manual mode (SBA + PD manual)
|
|
resolver = PlayResolver(league_id='sba', auto_mode=False)
|
|
|
|
# Auto mode (PD only, rare)
|
|
resolver = PlayResolver(league_id='pd', auto_mode=True)
|
|
```
|
|
|
|
**Resolution Methods**:
|
|
|
|
```python
|
|
# MANUAL: Player submits outcome from physical card
|
|
result = resolver.resolve_manual_play(
|
|
submission=ManualOutcomeSubmission(outcome='single_1', hit_location='CF'),
|
|
state=state,
|
|
defensive_decision=def_dec,
|
|
offensive_decision=off_dec,
|
|
ab_roll=ab_roll
|
|
)
|
|
|
|
# AUTO: System generates outcome (PD only)
|
|
result = resolver.resolve_auto_play(
|
|
state=state,
|
|
batter=pd_player,
|
|
pitcher=pd_pitcher,
|
|
defensive_decision=def_dec,
|
|
offensive_decision=off_dec
|
|
)
|
|
|
|
# CORE: All resolution logic lives here
|
|
result = resolver.resolve_outcome(
|
|
outcome=PlayOutcome.GROUNDBALL_A,
|
|
hit_location='SS',
|
|
state=state,
|
|
defensive_decision=def_dec,
|
|
offensive_decision=off_dec,
|
|
ab_roll=ab_roll
|
|
)
|
|
```
|
|
|
|
**PlayResult Structure**:
|
|
```python
|
|
@dataclass
|
|
class PlayResult:
|
|
outcome: PlayOutcome
|
|
outs_recorded: int
|
|
runs_scored: int
|
|
batter_result: Optional[int] # None=out, 1-4=base
|
|
runners_advanced: List[tuple[int, int]] # [(from, to), ...]
|
|
description: str
|
|
ab_roll: AbRoll
|
|
hit_location: Optional[str]
|
|
# Statistics
|
|
is_hit: bool
|
|
is_out: bool
|
|
is_walk: bool
|
|
```
|
|
|
|
**Runner Advancement Integration**:
|
|
- Delegates all groundball outcomes to RunnerAdvancement
|
|
- Delegates all flyball outcomes to RunnerAdvancement
|
|
- Converts AdvancementResult to PlayResult format
|
|
- Extracts batter movement from RunnerMovement list
|
|
|
|
**Supported Outcomes**:
|
|
- Strikeouts
|
|
- Groundballs (A, B, C) → delegates to RunnerAdvancement
|
|
- Flyouts (A, B, BQ, C) → delegates to RunnerAdvancement
|
|
- Lineouts
|
|
- Walks
|
|
- Singles (1, 2, uncapped)
|
|
- Doubles (2, 3, uncapped)
|
|
- Triples
|
|
- Home runs
|
|
- Wild pitch / Passed ball
|
|
|
|
---
|
|
|
|
### 4. runner_advancement.py
|
|
|
|
**Purpose**: Implements complete runner advancement system for groundballs and flyballs
|
|
|
|
**Key Classes**:
|
|
- `RunnerAdvancement`: Main logic handler (handles both groundballs and flyballs)
|
|
- `GroundballResultType`: Enum of 13 result types (matches rulebook charts)
|
|
- `RunnerMovement`: Single runner's movement
|
|
- `AdvancementResult`: Complete result with all movements
|
|
|
|
**Groundball Result Types** (1-13):
|
|
```python
|
|
1: BATTER_OUT_RUNNERS_HOLD
|
|
2: DOUBLE_PLAY_AT_SECOND
|
|
3: BATTER_OUT_RUNNERS_ADVANCE
|
|
4: BATTER_SAFE_FORCE_OUT_AT_SECOND
|
|
5: CONDITIONAL_ON_MIDDLE_INFIELD
|
|
6: CONDITIONAL_ON_RIGHT_SIDE
|
|
7: BATTER_OUT_FORCED_ONLY
|
|
8: BATTER_OUT_FORCED_ONLY_ALT
|
|
9: LEAD_HOLDS_TRAIL_ADVANCES
|
|
10: DOUBLE_PLAY_HOME_TO_FIRST
|
|
11: BATTER_SAFE_LEAD_OUT
|
|
12: DECIDE_OPPORTUNITY
|
|
13: CONDITIONAL_DOUBLE_PLAY
|
|
```
|
|
|
|
**Usage**:
|
|
```python
|
|
runner_advancement = RunnerAdvancement()
|
|
|
|
result = runner_advancement.advance_runners(
|
|
outcome=PlayOutcome.GROUNDBALL_A,
|
|
hit_location='SS',
|
|
state=state,
|
|
defensive_decision=defensive_decision
|
|
)
|
|
|
|
# Result contains:
|
|
result.movements # List[RunnerMovement]
|
|
result.outs_recorded # int
|
|
result.runs_scored # int
|
|
result.result_type # GroundballResultType
|
|
result.description # str
|
|
```
|
|
|
|
**Chart Selection Logic**:
|
|
```
|
|
Special case: 2 outs → Result 1 (batter out, runners hold)
|
|
|
|
Infield In (runner on 3rd scenarios):
|
|
- Applies when: defensive_decision.infield_depth == "infield_in"
|
|
- Affects bases: 3, 5, 6, 7 (any scenario with runner on 3rd)
|
|
- Uses: _apply_infield_in_chart()
|
|
|
|
Corners In (hybrid approach):
|
|
- Applies when: defensive_decision.infield_depth == "corners_in"
|
|
- Only for corner hits: P, C, 1B, 3B
|
|
- Middle infield (2B, SS) uses Infield Back
|
|
|
|
Infield Back (default):
|
|
- Normal defensive positioning
|
|
- Uses: _apply_infield_back_chart()
|
|
```
|
|
|
|
**DECIDE Opportunity** (Result 12):
|
|
- Lead runner can attempt to advance
|
|
- Offense chooses whether to attempt
|
|
- Defense responds (take sure out at 1st OR throw to lead runner)
|
|
- Currently simplified (conservative default)
|
|
- TODO: Interactive decision-making via WebSocket
|
|
|
|
---
|
|
|
|
**Flyball Types** (Direct Mapping):
|
|
|
|
Flyballs use direct outcome-to-behavior mapping (no chart needed):
|
|
|
|
| Outcome | Depth | R3 | R2 | R1 | Notes |
|
|
|---------|-------|----|----|-----|-------|
|
|
| **FLYOUT_A** | Deep | Advances (scores) | Advances to 3rd | Advances to 2nd | All runners tag up |
|
|
| **FLYOUT_B** | Medium | **Scores** | DECIDE (defaults hold) | Holds | Sac fly + DECIDE |
|
|
| **FLYOUT_BQ** | Medium-shallow | DECIDE (defaults hold) | Holds | Holds | fly(b)? from cards |
|
|
| **FLYOUT_C** | Shallow | Holds | Holds | Holds | Too shallow to tag |
|
|
|
|
**Usage**:
|
|
```python
|
|
runner_advancement = RunnerAdvancement()
|
|
|
|
# Flyball advancement (same interface as groundballs)
|
|
result = runner_advancement.advance_runners(
|
|
outcome=PlayOutcome.FLYOUT_B,
|
|
hit_location='RF', # LF, CF, or RF
|
|
state=state,
|
|
defensive_decision=defensive_decision
|
|
)
|
|
|
|
# Result contains:
|
|
result.movements # List[RunnerMovement]
|
|
result.outs_recorded # Always 1 for flyouts
|
|
result.runs_scored # 0-3 depending on runners
|
|
result.result_type # None (flyballs don't use result types)
|
|
result.description # e.g., "Medium flyball to RF - R3 scores, R2 DECIDE (held), R1 holds"
|
|
```
|
|
|
|
**Key Differences from Groundballs**:
|
|
1. **No Chart Lookup**: Direct mapping from outcome to behavior
|
|
2. **No Result Type**: `result_type` is `None` for flyballs (groundballs use 1-13)
|
|
3. **DECIDE Mechanics**:
|
|
- FLYOUT_B: R2 may attempt to tag to 3rd (currently defaults to hold)
|
|
- FLYOUT_BQ: R3 may attempt to score (currently defaults to hold)
|
|
- TODO: Interactive DECIDE with probability calculations (arm strength, runner speed)
|
|
4. **No-op Movements**: Hold movements are recorded for state recovery (same as groundballs)
|
|
|
|
**Special Cases**:
|
|
- **2 outs**: No runner advancement recorded (inning ends)
|
|
- **Empty bases**: Only batter movement (out at plate)
|
|
- **Hit location**: Used in description and future DECIDE probability calculations
|
|
|
|
**Test Coverage**:
|
|
- 21 flyball tests in `tests/unit/core/test_flyball_advancement.py`
|
|
- Coverage: All 4 types, DECIDE scenarios, no-op movements, edge cases
|
|
|
|
---
|
|
|
|
**X-Check Placeholder Functions** (Phase 3B - 2025-11-01):
|
|
|
|
X-Check resolution functions for defensive plays triggered by dice rolls. Currently placeholders awaiting Phase 3C implementation.
|
|
|
|
**Functions**:
|
|
- `x_check_g1(on_base_code, defender_in, error_result)` → AdvancementResult
|
|
- `x_check_g2(on_base_code, defender_in, error_result)` → AdvancementResult
|
|
- `x_check_g3(on_base_code, defender_in, error_result)` → AdvancementResult
|
|
- `x_check_f1(on_base_code, error_result)` → AdvancementResult
|
|
- `x_check_f2(on_base_code, error_result)` → AdvancementResult
|
|
- `x_check_f3(on_base_code, error_result)` → AdvancementResult
|
|
|
|
**Arguments**:
|
|
- `on_base_code`: Current base situation (0-7 bit field: 1=R1, 2=R2, 4=R3)
|
|
- `defender_in`: Boolean indicating if defender is playing in
|
|
- `error_result`: Error type from 3d6 roll ('NO', 'E1', 'E2', 'E3', 'RP')
|
|
|
|
**Current Implementation**:
|
|
All functions return placeholder `AdvancementResult` with empty movements. Will be implemented in Phase 3C using X-Check tables from `app.config.common_x_check_tables`.
|
|
|
|
**Usage** (Future):
|
|
```python
|
|
from app.core.runner_advancement import x_check_g1
|
|
|
|
result = x_check_g1(
|
|
on_base_code=5, # R1 and R3
|
|
defender_in=False,
|
|
error_result='NO'
|
|
)
|
|
# Will return complete AdvancementResult with runner movements
|
|
```
|
|
|
|
**Test Coverage**: 9 tests in `tests/unit/core/test_runner_advancement.py::TestXCheckPlaceholders`
|
|
|
|
---
|
|
|
|
### 5. dice.py
|
|
|
|
**Purpose**: Cryptographically secure dice rolling system
|
|
|
|
**Key Classes**:
|
|
- `DiceSystem`: Singleton dice roller
|
|
|
|
**Roll Types**:
|
|
- `AbRoll`: At-bat (1d6 + 2d6 + 2d20)
|
|
- `JumpRoll`: Stolen base attempt
|
|
- `FieldingRoll`: Defensive play (1d20 + 3d6 + 1d100)
|
|
- `D20Roll`: Generic d20
|
|
|
|
**Usage**:
|
|
```python
|
|
from app.core.dice import dice_system
|
|
|
|
# At-bat roll
|
|
ab_roll = dice_system.roll_ab(league_id='sba', game_id=game_id)
|
|
# Returns: AbRoll with d6_one, d6_two_a, d6_two_b, chaos_d20, resolution_d20
|
|
|
|
# Jump roll (stolen base)
|
|
jump_roll = dice_system.roll_jump(league_id='sba', game_id=game_id)
|
|
|
|
# Fielding roll
|
|
fielding_roll = dice_system.roll_fielding(position='SS', league_id='sba', game_id=game_id)
|
|
|
|
# Generic d20
|
|
d20_roll = dice_system.roll_d20(league_id='sba', game_id=game_id)
|
|
```
|
|
|
|
**Roll History**:
|
|
```python
|
|
# Get recent rolls
|
|
rolls = dice_system.get_roll_history(roll_type=RollType.AB, game_id=game_id, limit=10)
|
|
|
|
# Get rolls since timestamp (for batch saving)
|
|
rolls = dice_system.get_rolls_since(game_id, since_timestamp)
|
|
|
|
# Verify roll authenticity
|
|
is_valid = dice_system.verify_roll(roll_id)
|
|
|
|
# Statistics
|
|
stats = dice_system.get_distribution_stats(roll_type=RollType.AB)
|
|
```
|
|
|
|
**Security**:
|
|
- Uses Python's `secrets` module (cryptographically secure)
|
|
- Unique roll_id for each roll (16 char hex)
|
|
- Complete audit trail in roll history
|
|
- Cannot be predicted or manipulated
|
|
|
|
---
|
|
|
|
### 6. validators.py
|
|
|
|
**Purpose**: Enforce baseball rules and validate game actions
|
|
|
|
**Key Classes**:
|
|
- `GameValidator`: Static validation methods
|
|
- `ValidationError`: Custom exception for rule violations
|
|
|
|
**Common Validations**:
|
|
```python
|
|
from app.core.validators import game_validator, ValidationError
|
|
|
|
# Game status
|
|
game_validator.validate_game_active(state)
|
|
|
|
# Defensive decision
|
|
game_validator.validate_defensive_decision(decision, state)
|
|
# Checks:
|
|
# - Valid depth choices
|
|
# - Can't hold runner on empty base
|
|
# - Infield in/corners in requires runner on 3rd
|
|
|
|
# Offensive decision
|
|
game_validator.validate_offensive_decision(decision, state)
|
|
# Checks:
|
|
# - Valid approach
|
|
# - Can't steal from empty base
|
|
# - Can't bunt with 2 outs
|
|
# - Hit-and-run requires runner on base
|
|
|
|
# Defensive lineup
|
|
game_validator.validate_defensive_lineup_positions(lineup)
|
|
# Checks:
|
|
# - Exactly 1 active player per position (P, C, 1B, 2B, 3B, SS, LF, CF, RF)
|
|
# - Called at game start and start of each half-inning
|
|
|
|
# Game state
|
|
can_continue = game_validator.can_continue_inning(state) # outs < 3
|
|
is_over = game_validator.is_game_over(state) # inning >= 9 and score not tied
|
|
```
|
|
|
|
---
|
|
|
|
### 7. ai_opponent.py
|
|
|
|
**Purpose**: AI decision-making for AI-controlled teams
|
|
|
|
**Status**: Week 7 stub implementation (full AI in Week 9)
|
|
|
|
**Key Classes**:
|
|
- `AIOpponent`: Decision generator
|
|
|
|
**Current Implementation**:
|
|
```python
|
|
from app.core.ai_opponent import ai_opponent
|
|
|
|
# Generate defensive decision
|
|
decision = await ai_opponent.generate_defensive_decision(state)
|
|
# Returns: DefensiveDecision with default "normal" settings
|
|
|
|
# Generate offensive decision
|
|
decision = await ai_opponent.generate_offensive_decision(state)
|
|
# Returns: OffensiveDecision with default "normal" approach
|
|
```
|
|
|
|
**Difficulty Levels**:
|
|
- `"balanced"`: Standard decision-making (current default)
|
|
- `"yolo"`: Aggressive playstyle (more risks) - TODO Week 9
|
|
- `"safe"`: Conservative playstyle (fewer risks) - TODO Week 9
|
|
|
|
**TODO Week 9**:
|
|
- Analyze batter tendencies for defensive positioning
|
|
- Consider runner speed for hold decisions
|
|
- Evaluate double play opportunities
|
|
- Implement stealing logic based on runner speed, pitcher hold, catcher arm
|
|
- Implement bunting decisions
|
|
- Adjust strategies based on game situation (score, inning, outs)
|
|
|
|
---
|
|
|
|
### 8. roll_types.py
|
|
|
|
**Purpose**: Type-safe dice roll data structures
|
|
|
|
**Key Classes**:
|
|
- `RollType`: Enum of roll types (AB, JUMP, FIELDING, D20)
|
|
- `DiceRoll`: Base class with auditing fields
|
|
- `AbRoll`: At-bat roll (1d6 + 2d6 + 2d20)
|
|
- `JumpRoll`: Baserunning roll
|
|
- `FieldingRoll`: Defensive play roll
|
|
- `D20Roll`: Generic d20 roll
|
|
|
|
**AbRoll Structure**:
|
|
```python
|
|
@dataclass(kw_only=True)
|
|
class AbRoll(DiceRoll):
|
|
# Required dice
|
|
d6_one: int # 1-6
|
|
d6_two_a: int # 1-6
|
|
d6_two_b: int # 1-6
|
|
chaos_d20: int # 1-20 (1=WP, 2=PB, 3+=normal)
|
|
resolution_d20: int # 1-20 (for WP/PB resolution or split results)
|
|
|
|
# Derived values
|
|
d6_two_total: int # Sum of 2d6
|
|
check_wild_pitch: bool # chaos_d20 == 1
|
|
check_passed_ball: bool # chaos_d20 == 2
|
|
```
|
|
|
|
**Roll Flow**:
|
|
```
|
|
1. Roll chaos_d20 first
|
|
2. If chaos_d20 == 1: Check wild pitch (use resolution_d20)
|
|
3. If chaos_d20 == 2: Check passed ball (use resolution_d20)
|
|
4. If chaos_d20 >= 3: Normal at-bat (use chaos_d20 for result, resolution_d20 for splits)
|
|
```
|
|
|
|
**Auditing Fields** (on all rolls):
|
|
```python
|
|
roll_id: str # Unique cryptographic ID
|
|
roll_type: RollType # AB, JUMP, FIELDING, D20
|
|
league_id: str # 'sba' or 'pd'
|
|
timestamp: pendulum.DateTime
|
|
game_id: Optional[UUID]
|
|
team_id: Optional[int]
|
|
player_id: Optional[int] # Polymorphic: player_id (SBA) or card_id (PD)
|
|
context: Optional[Dict] # Additional metadata (JSONB)
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### 1. Async Database Operations
|
|
|
|
All database operations use async/await and don't block game logic:
|
|
|
|
```python
|
|
# Write operations are fire-and-forget
|
|
await self.db_ops.save_play(play_data)
|
|
await self.db_ops.update_game_state(...)
|
|
|
|
# But still use try/except for error handling
|
|
try:
|
|
await self.db_ops.save_play(play_data)
|
|
except Exception as e:
|
|
logger.error(f"Failed to save play: {e}")
|
|
# Game continues - play is in memory
|
|
```
|
|
|
|
### 2. State Snapshot Pattern
|
|
|
|
GameEngine prepares a snapshot BEFORE each play for database persistence:
|
|
|
|
```python
|
|
# BEFORE play
|
|
await self._prepare_next_play(state) # Sets snapshot fields
|
|
|
|
# Snapshot fields used by _save_play_to_db():
|
|
state.current_batter_lineup_id # Who's batting
|
|
state.current_pitcher_lineup_id # Who's pitching
|
|
state.current_catcher_lineup_id # Who's catching
|
|
state.current_on_base_code # Bit field (1=1st, 2=2nd, 4=3rd, 7=loaded)
|
|
|
|
# AFTER play
|
|
result = await self.resolve_play(...)
|
|
await self._save_play_to_db(state, result) # Uses snapshot
|
|
```
|
|
|
|
### 3. Orchestration Sequence
|
|
|
|
GameEngine always follows this sequence:
|
|
|
|
```python
|
|
# STEP 1: Resolve play
|
|
result = resolver.resolve_outcome(...)
|
|
|
|
# STEP 2: Save play to DB (uses snapshot)
|
|
await self._save_play_to_db(state, result)
|
|
|
|
# STEP 3: Apply result to state
|
|
self._apply_play_result(state, result)
|
|
|
|
# STEP 4: Update game state in DB (conditional)
|
|
if state_changed:
|
|
await self.db_ops.update_game_state(...)
|
|
|
|
# STEP 5: Check for inning change
|
|
if state.outs >= 3:
|
|
await self._advance_inning(state, game_id)
|
|
# Batch save rolls at half-inning boundary
|
|
await self._batch_save_inning_rolls(game_id)
|
|
|
|
# STEP 6: Prepare next play (always last)
|
|
await self._prepare_next_play(state)
|
|
```
|
|
|
|
### 4. Lineup Caching
|
|
|
|
StateManager caches lineups to avoid redundant DB queries:
|
|
|
|
```python
|
|
# First access - fetches from DB
|
|
lineup = await db_ops.get_active_lineup(game_id, team_id)
|
|
lineup_state = TeamLineupState(team_id=team_id, players=[...])
|
|
state_manager.set_lineup(game_id, team_id, lineup_state)
|
|
|
|
# Subsequent accesses - uses cache
|
|
lineup_state = state_manager.get_lineup(game_id, team_id)
|
|
|
|
# Cache persists for entire game
|
|
```
|
|
|
|
### 5. Error Handling Pattern
|
|
|
|
Core modules use "Raise or Return" pattern:
|
|
|
|
```python
|
|
# ✅ DO: Raise exceptions for invalid states
|
|
if game_id not in self._states:
|
|
raise ValueError(f"Game {game_id} not found")
|
|
|
|
# ✅ DO: Return None only when semantically valid
|
|
def get_state(self, game_id: UUID) -> Optional[GameState]:
|
|
return self._states.get(game_id)
|
|
|
|
# ❌ DON'T: Return Optional[T] unless specifically required
|
|
def process_action(...) -> Optional[Result]: # Avoid this pattern
|
|
```
|
|
|
|
## Integration Points
|
|
|
|
### With Database Layer
|
|
|
|
```python
|
|
from app.database.operations import DatabaseOperations
|
|
|
|
db_ops = DatabaseOperations()
|
|
|
|
# Game CRUD
|
|
await db_ops.create_game(game_id, league_id, home_id, away_id, ...)
|
|
await db_ops.update_game_state(game_id, inning, half, home_score, away_score, status)
|
|
|
|
# Play persistence
|
|
await db_ops.save_play(play_data)
|
|
plays = await db_ops.get_plays(game_id, limit=10)
|
|
|
|
# Lineup management
|
|
lineup_id = await db_ops.create_lineup_entry(game_id, team_id, card_id, position, ...)
|
|
lineup = await db_ops.get_active_lineup(game_id, team_id)
|
|
|
|
# State recovery
|
|
game_data = await db_ops.load_game_state(game_id)
|
|
|
|
# Batch operations
|
|
await db_ops.save_rolls_batch(rolls)
|
|
|
|
# Rollback
|
|
deleted = await db_ops.delete_plays_after(game_id, play_number)
|
|
deleted_subs = await db_ops.delete_substitutions_after(game_id, play_number)
|
|
```
|
|
|
|
### With Models
|
|
|
|
```python
|
|
from app.models.game_models import (
|
|
GameState, DefensiveDecision, OffensiveDecision,
|
|
LineupPlayerState, TeamLineupState, ManualOutcomeSubmission
|
|
)
|
|
|
|
# Game state
|
|
state = GameState(
|
|
game_id=game_id,
|
|
league_id='sba',
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
|
|
# Strategic decisions
|
|
def_decision = DefensiveDecision(
|
|
alignment='shifted_left',
|
|
infield_depth='normal',
|
|
outfield_depth='normal',
|
|
hold_runners=[3]
|
|
)
|
|
|
|
off_decision = OffensiveDecision(
|
|
approach='power',
|
|
steal_attempts=[2],
|
|
hit_and_run=False,
|
|
bunt_attempt=False
|
|
)
|
|
|
|
# Manual outcome submission
|
|
submission = ManualOutcomeSubmission(
|
|
outcome='groundball_a',
|
|
hit_location='SS'
|
|
)
|
|
```
|
|
|
|
### With Config
|
|
|
|
```python
|
|
from app.config import PlayOutcome, get_league_config
|
|
|
|
# Get league configuration
|
|
config = get_league_config(state.league_id)
|
|
|
|
# Check league capabilities
|
|
supports_auto = config.supports_auto_mode() # PD: True, SBA: False
|
|
|
|
# Play outcomes
|
|
outcome = PlayOutcome.GROUNDBALL_A
|
|
|
|
# Outcome helpers
|
|
if outcome.is_hit():
|
|
print("Hit!")
|
|
if outcome.is_uncapped():
|
|
# Trigger advancement decision tree
|
|
pass
|
|
if outcome.requires_hit_location():
|
|
# Must specify where ball was hit
|
|
pass
|
|
```
|
|
|
|
### With WebSocket (Future)
|
|
|
|
```python
|
|
# TODO Week 7-8: WebSocket integration
|
|
|
|
# Emit state updates
|
|
await connection_manager.broadcast_to_game(
|
|
game_id,
|
|
'game_state_update',
|
|
state_data
|
|
)
|
|
|
|
# Request decisions
|
|
await connection_manager.emit_decision_required(
|
|
game_id=game_id,
|
|
team_id=team_id,
|
|
decision_type='defensive',
|
|
timeout=30,
|
|
game_situation=state.to_situation_summary()
|
|
)
|
|
|
|
# Broadcast play result
|
|
await connection_manager.broadcast_play_result(
|
|
game_id,
|
|
result_data
|
|
)
|
|
```
|
|
|
|
## Common Tasks
|
|
|
|
### Starting a New Game
|
|
|
|
```python
|
|
from app.core.game_engine import game_engine
|
|
from app.core.state_manager import state_manager
|
|
|
|
# 1. Create game in database
|
|
game_id = uuid4()
|
|
await db_ops.create_game(
|
|
game_id=game_id,
|
|
league_id='sba',
|
|
home_team_id=1,
|
|
away_team_id=2,
|
|
game_mode='friendly',
|
|
visibility='public'
|
|
)
|
|
|
|
# 2. Create state in memory
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id='sba',
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
|
|
# 3. Set up lineups (must have 9+ players, all positions filled)
|
|
# ... create lineup entries in database ...
|
|
|
|
# 4. Start game (transitions from 'pending' to 'active')
|
|
state = await game_engine.start_game(game_id)
|
|
```
|
|
|
|
### Resolving a Play (Manual Mode)
|
|
|
|
```python
|
|
# 1. Get decisions
|
|
await game_engine.submit_defensive_decision(game_id, defensive_decision)
|
|
await game_engine.submit_offensive_decision(game_id, offensive_decision)
|
|
|
|
# 2. Player submits outcome from physical card
|
|
submission = ManualOutcomeSubmission(
|
|
outcome='groundball_a',
|
|
hit_location='SS'
|
|
)
|
|
|
|
# 3. Server rolls dice for audit trail
|
|
ab_roll = dice_system.roll_ab(league_id='sba', game_id=game_id)
|
|
|
|
# 4. Resolve play
|
|
result = await game_engine.resolve_manual_play(
|
|
game_id=game_id,
|
|
ab_roll=ab_roll,
|
|
outcome=PlayOutcome(submission.outcome),
|
|
hit_location=submission.hit_location
|
|
)
|
|
|
|
# Result is automatically:
|
|
# - Saved to database
|
|
# - Applied to game state
|
|
# - Prepared for next play
|
|
```
|
|
|
|
### Resolving a Play (Auto Mode - PD Only)
|
|
|
|
```python
|
|
# 1. Get decisions (same as manual)
|
|
|
|
# 2. Fetch player data with ratings
|
|
batter = await api_client.get_pd_player(batter_id, include_ratings=True)
|
|
pitcher = await api_client.get_pd_player(pitcher_id, include_ratings=True)
|
|
|
|
# 3. Create resolver in auto mode
|
|
resolver = PlayResolver(league_id='pd', auto_mode=True)
|
|
|
|
# 4. Auto-generate outcome from ratings
|
|
result = resolver.resolve_auto_play(
|
|
state=state,
|
|
batter=batter,
|
|
pitcher=pitcher,
|
|
defensive_decision=defensive_decision,
|
|
offensive_decision=offensive_decision
|
|
)
|
|
|
|
# 5. Apply to game engine (same as manual)
|
|
await game_engine.resolve_play(game_id)
|
|
```
|
|
|
|
### Adding a New PlayOutcome
|
|
|
|
```python
|
|
# 1. Add to PlayOutcome enum (app/config/result_charts.py)
|
|
class PlayOutcome(str, Enum):
|
|
NEW_OUTCOME = "new_outcome"
|
|
|
|
# 2. Add helper method if needed
|
|
def is_new_category(self) -> bool:
|
|
return self in [PlayOutcome.NEW_OUTCOME]
|
|
|
|
# 3. Add resolution logic (app/core/play_resolver.py)
|
|
def resolve_outcome(self, outcome, ...):
|
|
# ... existing outcomes ...
|
|
|
|
elif outcome == PlayOutcome.NEW_OUTCOME:
|
|
# Calculate movements
|
|
runners_advanced = self._advance_on_new_outcome(state)
|
|
runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=...,
|
|
runs_scored=runs_scored,
|
|
batter_result=...,
|
|
runners_advanced=runners_advanced,
|
|
description="...",
|
|
ab_roll=ab_roll,
|
|
is_hit=True/False
|
|
)
|
|
|
|
# 4. Add advancement helper method
|
|
def _advance_on_new_outcome(self, state: GameState) -> List[tuple[int, int]]:
|
|
advances = []
|
|
# ... calculate runner movements ...
|
|
return advances
|
|
|
|
# 5. Add unit tests (tests/unit/core/test_play_resolver.py)
|
|
```
|
|
|
|
### Recovering a Game After Restart
|
|
|
|
```python
|
|
# 1. Load game from database
|
|
state = await state_manager.recover_game(game_id)
|
|
|
|
# 2. State is automatically:
|
|
# - Rebuilt from last completed play
|
|
# - Runner positions recovered
|
|
# - Batter indices set
|
|
# - Cached in memory
|
|
|
|
# 3. Game ready to continue
|
|
# (Next call to _prepare_next_play will correct batter index if needed)
|
|
```
|
|
|
|
### Rolling Back Plays
|
|
|
|
```python
|
|
# Roll back last 3 plays
|
|
state = await game_engine.rollback_plays(game_id, num_plays=3)
|
|
|
|
# This automatically:
|
|
# - Deletes plays from database
|
|
# - Deletes related substitutions
|
|
# - Recovers game state by replaying remaining plays
|
|
# - Updates in-memory state
|
|
```
|
|
|
|
### Debugging Game State
|
|
|
|
```python
|
|
# Get current state
|
|
state = state_manager.get_state(game_id)
|
|
|
|
# Print state details
|
|
print(f"Inning {state.inning} {state.half}")
|
|
print(f"Score: {state.away_score} - {state.home_score}")
|
|
print(f"Outs: {state.outs}")
|
|
print(f"Runners: {state.get_all_runners()}")
|
|
print(f"Play count: {state.play_count}")
|
|
|
|
# Check decisions
|
|
print(f"Pending: {state.pending_decision}")
|
|
print(f"Decisions: {state.decisions_this_play}")
|
|
|
|
# Check batter
|
|
print(f"Batter lineup_id: {state.current_batter_lineup_id}")
|
|
|
|
# Get statistics
|
|
stats = state_manager.get_stats()
|
|
print(f"Active games: {stats['active_games']}")
|
|
print(f"By league: {stats['games_by_league']}")
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Issue: Game state not found
|
|
|
|
**Symptom**: `ValueError: Game {game_id} not found`
|
|
|
|
**Causes**:
|
|
1. Game was evicted from memory (idle timeout)
|
|
2. Game never created in state manager
|
|
3. Server restarted
|
|
|
|
**Solutions**:
|
|
```python
|
|
# Try recovery first
|
|
state = await state_manager.recover_game(game_id)
|
|
|
|
# If still None, game doesn't exist in database
|
|
if not state:
|
|
# Create new game or return error to client
|
|
```
|
|
|
|
### Issue: outs_before incorrect in database
|
|
|
|
**Symptom**: Play records show wrong outs_before value
|
|
|
|
**Cause**: Play saved AFTER outs were applied to state
|
|
|
|
**Solution**: This was fixed in 2025-10-28 update. Ensure `_save_play_to_db` is called in STEP 2 (before `_apply_play_result`).
|
|
|
|
```python
|
|
# CORRECT sequence:
|
|
result = resolver.resolve_outcome(...) # STEP 1
|
|
await self._save_play_to_db(state, result) # STEP 2 - outs not yet applied
|
|
self._apply_play_result(state, result) # STEP 3 - outs applied here
|
|
```
|
|
|
|
### Issue: Cannot find batter/pitcher in lineup
|
|
|
|
**Symptom**: `ValueError: Cannot save play: batter_id is None`
|
|
|
|
**Cause**: `_prepare_next_play()` not called before play resolution
|
|
|
|
**Solution**:
|
|
```python
|
|
# Always call _prepare_next_play before resolving play
|
|
await self._prepare_next_play(state)
|
|
|
|
# This is handled automatically by game_engine orchestration
|
|
# But check if you're calling resolver directly
|
|
```
|
|
|
|
### Issue: Runner positions lost after recovery
|
|
|
|
**Symptom**: Recovered game has no runners on base but should have runners
|
|
|
|
**Cause**: Last play was incomplete or not marked complete
|
|
|
|
**Solution**:
|
|
```python
|
|
# Only complete plays are used for recovery
|
|
# Verify play was marked complete in database
|
|
play_data['complete'] = True
|
|
|
|
# Check if last play exists
|
|
plays = await db_ops.get_plays(game_id, limit=1)
|
|
if not plays:
|
|
# No plays saved - fresh game
|
|
pass
|
|
```
|
|
|
|
### Issue: Lineup cache out of sync
|
|
|
|
**Symptom**: Wrong player batting or incorrect defensive positions
|
|
|
|
**Cause**: Lineup modified in database but cache not updated
|
|
|
|
**Solution**:
|
|
```python
|
|
# After lineup changes, update cache
|
|
lineup = await db_ops.get_active_lineup(game_id, team_id)
|
|
lineup_state = TeamLineupState(team_id=team_id, players=[...])
|
|
state_manager.set_lineup(game_id, team_id, lineup_state)
|
|
|
|
# Or clear and recover game
|
|
state_manager.remove_game(game_id)
|
|
state = await state_manager.recover_game(game_id)
|
|
```
|
|
|
|
### Issue: Integration tests fail with connection errors
|
|
|
|
**Symptom**: `asyncpg connection closed` or `event loop closed`
|
|
|
|
**Cause**: Database connection pooling conflicts when tests run in parallel
|
|
|
|
**Solution**:
|
|
```bash
|
|
# Run integration tests individually
|
|
uv run pytest tests/integration/test_game_engine.py::TestGameEngine::test_resolve_play -v
|
|
|
|
# Or use -x flag to stop on first failure
|
|
uv run pytest tests/integration/test_game_engine.py -x -v
|
|
```
|
|
|
|
### Issue: Type errors with SQLAlchemy models
|
|
|
|
**Symptom**: `Type "Column[int]" is not assignable to type "int | None"`
|
|
|
|
**Cause**: Known false positive - SQLAlchemy Column is int at runtime
|
|
|
|
**Solution**: Use targeted type: ignore comment
|
|
```python
|
|
state.current_batter_lineup_id = lineup_player.id # type: ignore[assignment]
|
|
```
|
|
|
|
See `backend/CLAUDE.md` section on Type Checking for comprehensive guidance.
|
|
|
|
## Testing
|
|
|
|
### Unit Tests
|
|
|
|
```bash
|
|
# All core unit tests
|
|
uv run pytest tests/unit/core/ -v
|
|
|
|
# Specific module
|
|
uv run pytest tests/unit/core/test_game_engine.py -v
|
|
uv run pytest tests/unit/core/test_play_resolver.py -v
|
|
uv run pytest tests/unit/core/test_runner_advancement.py -v # Groundball tests (30 tests)
|
|
uv run pytest tests/unit/core/test_flyball_advancement.py -v # Flyball tests (21 tests)
|
|
uv run pytest tests/unit/core/test_state_manager.py -v
|
|
|
|
# With coverage
|
|
uv run pytest tests/unit/core/ --cov=app.core --cov-report=html
|
|
```
|
|
|
|
### Integration Tests
|
|
|
|
```bash
|
|
# All core integration tests
|
|
uv run pytest tests/integration/test_state_persistence.py -v
|
|
|
|
# Run individually (recommended due to connection pooling)
|
|
uv run pytest tests/integration/test_game_engine.py::TestGameEngine::test_complete_game -v
|
|
```
|
|
|
|
### Terminal Client
|
|
|
|
Best tool for testing game engine in isolation:
|
|
|
|
```bash
|
|
# Start REPL
|
|
uv run python -m terminal_client
|
|
|
|
# Create and play game
|
|
⚾ > new_game
|
|
⚾ > defensive
|
|
⚾ > offensive
|
|
⚾ > resolve
|
|
⚾ > status
|
|
⚾ > quick_play 10
|
|
⚾ > quit
|
|
```
|
|
|
|
See `terminal_client/CLAUDE.md` for full documentation.
|
|
|
|
## Performance Notes
|
|
|
|
### Current Performance
|
|
|
|
- State access: O(1) dictionary lookup (~1μs)
|
|
- Play resolution: 50-100ms (includes DB write)
|
|
- Lineup cache: Eliminates 2 SELECT queries per play
|
|
- Conditional updates: ~40-60% fewer UPDATE queries
|
|
|
|
### Optimization History
|
|
|
|
**2025-10-28: 60% Query Reduction**
|
|
- Before: 5 queries per play
|
|
- After: 2 queries per play (INSERT + UPDATE)
|
|
- Changes:
|
|
- Added lineup caching
|
|
- Removed unnecessary refresh after save
|
|
- Direct UPDATE statements
|
|
- Conditional game state updates
|
|
|
|
### Memory Usage
|
|
|
|
- GameState: ~1-2KB per game
|
|
- TeamLineupState: ~500 bytes per team
|
|
- Roll history: ~200 bytes per roll
|
|
- Total per active game: ~3-5KB
|
|
|
|
### Scaling Targets
|
|
|
|
- Support: 10+ simultaneous games
|
|
- Memory: <1GB with 10 active games
|
|
- Response: <500ms action to state update
|
|
- Recovery: <2 seconds from database
|
|
|
|
## Examples
|
|
|
|
### Complete Game Flow
|
|
|
|
```python
|
|
from uuid import uuid4
|
|
from app.core.game_engine import game_engine
|
|
from app.core.state_manager import state_manager
|
|
from app.models.game_models import DefensiveDecision, OffensiveDecision
|
|
from app.config import PlayOutcome
|
|
|
|
# 1. Create game
|
|
game_id = uuid4()
|
|
await db_ops.create_game(game_id, 'sba', home_id=1, away_id=2)
|
|
state = await state_manager.create_game(game_id, 'sba', 1, 2)
|
|
|
|
# 2. Set up lineups
|
|
# ... create 9+ players per team in database ...
|
|
|
|
# 3. Start game
|
|
state = await game_engine.start_game(game_id)
|
|
|
|
# 4. Submit decisions
|
|
def_dec = DefensiveDecision(alignment='normal', infield_depth='normal', outfield_depth='normal')
|
|
off_dec = OffensiveDecision(approach='normal')
|
|
|
|
await game_engine.submit_defensive_decision(game_id, def_dec)
|
|
await game_engine.submit_offensive_decision(game_id, off_dec)
|
|
|
|
# 5. Resolve play (manual mode)
|
|
ab_roll = dice_system.roll_ab('sba', game_id)
|
|
result = await game_engine.resolve_manual_play(
|
|
game_id=game_id,
|
|
ab_roll=ab_roll,
|
|
outcome=PlayOutcome.GROUNDBALL_A,
|
|
hit_location='SS'
|
|
)
|
|
|
|
# 6. Check result
|
|
print(f"{result.description}")
|
|
print(f"Outs: {result.outs_recorded}, Runs: {result.runs_scored}")
|
|
print(f"Batter result: {result.batter_result}")
|
|
|
|
# 7. Continue game loop
|
|
state = state_manager.get_state(game_id)
|
|
print(f"Score: {state.away_score} - {state.home_score}")
|
|
print(f"Inning {state.inning} {state.half}, {state.outs} outs")
|
|
```
|
|
|
|
### Groundball Resolution
|
|
|
|
```python
|
|
from app.core.runner_advancement import RunnerAdvancement
|
|
from app.models.game_models import GameState, DefensiveDecision
|
|
from app.config import PlayOutcome
|
|
|
|
# Set up situation
|
|
state = GameState(game_id=game_id, league_id='sba', ...)
|
|
state.on_first = LineupPlayerState(lineup_id=1, card_id=101, position='RF')
|
|
state.on_third = LineupPlayerState(lineup_id=2, card_id=102, position='CF')
|
|
state.outs = 1
|
|
state.current_on_base_code = 5 # 1st and 3rd
|
|
|
|
# Defensive positioning
|
|
def_dec = DefensiveDecision(infield_depth='infield_in')
|
|
|
|
# Resolve groundball
|
|
runner_adv = RunnerAdvancement()
|
|
result = runner_adv.advance_runners(
|
|
outcome=PlayOutcome.GROUNDBALL_A,
|
|
hit_location='SS',
|
|
state=state,
|
|
defensive_decision=def_dec
|
|
)
|
|
|
|
# Check result
|
|
print(f"Result type: {result.result_type}") # GroundballResultType.BATTER_OUT_FORCED_ONLY
|
|
print(f"Description: {result.description}")
|
|
print(f"Outs recorded: {result.outs_recorded}")
|
|
print(f"Runs scored: {result.runs_scored}")
|
|
|
|
# Check movements
|
|
for movement in result.movements:
|
|
print(f" {movement}")
|
|
```
|
|
|
|
### AI Decision Making
|
|
|
|
```python
|
|
from app.core.ai_opponent import ai_opponent
|
|
|
|
# Check if team is AI-controlled
|
|
if state.is_fielding_team_ai():
|
|
# Generate defensive decision
|
|
def_decision = await ai_opponent.generate_defensive_decision(state)
|
|
await game_engine.submit_defensive_decision(game_id, def_decision)
|
|
|
|
if state.is_batting_team_ai():
|
|
# Generate offensive decision
|
|
off_decision = await ai_opponent.generate_offensive_decision(state)
|
|
await game_engine.submit_offensive_decision(game_id, off_decision)
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
The `core` directory is the beating heart of the baseball simulation engine. It manages in-memory game state for fast performance, orchestrates complete gameplay flow, resolves play outcomes using card-based mechanics, and enforces all baseball rules.
|
|
|
|
**Key files**:
|
|
- `state_manager.py` - O(1) state lookups, lineup caching, recovery
|
|
- `game_engine.py` - Orchestration, workflow, persistence
|
|
- `play_resolver.py` - Outcome-first resolution (manual + auto)
|
|
- `runner_advancement.py` - Groundball (13 result types) & flyball (4 types) advancement
|
|
- `dice.py` - Cryptographic dice rolling system
|
|
- `validators.py` - Rule enforcement
|
|
- `ai_opponent.py` - AI decision-making (stub)
|
|
- `roll_types.py` - Type-safe roll data structures
|
|
|
|
**Architecture principles**:
|
|
- Async-first: All DB operations are non-blocking
|
|
- Outcome-first: Manual submissions are primary workflow
|
|
- Type-safe: Pydantic models throughout
|
|
- Single source of truth: StateManager for active games
|
|
- Raise or Return: No Optional unless semantically valid
|
|
|
|
**Performance**: 50-100ms play resolution, 60% query reduction, <500ms target achieved
|
|
|
|
**Next phase**: WebSocket integration (Week 7-8) for real-time multiplayer gameplay
|