## Refactoring - Changed `ManualOutcomeSubmission.outcome` from `str` to `PlayOutcome` enum type - Removed custom validator (Pydantic handles enum validation automatically) - Added direct import of PlayOutcome (no circular dependency due to TYPE_CHECKING guard) - Updated tests to use enum values while maintaining backward compatibility Benefits: - Better type safety with IDE autocomplete - Cleaner code (removed 15 lines of validator boilerplate) - Backward compatible (Pydantic auto-converts strings to enum) - Access to helper methods (is_hit(), is_out(), etc.) Files modified: - app/models/game_models.py: Enum type + import - tests/unit/config/test_result_charts.py: Updated 7 tests + added compatibility test ## Documentation Created comprehensive CLAUDE.md files for all backend/app/ subdirectories to help future AI agents quickly understand and work with the code. Added 8,799 lines of documentation covering: - api/ (906 lines): FastAPI routes, health checks, auth patterns - config/ (906 lines): League configs, PlayOutcome enum, result charts - core/ (1,288 lines): GameEngine, StateManager, PlayResolver, dice system - data/ (937 lines): API clients (planned), caching layer - database/ (945 lines): Async sessions, operations, recovery - models/ (1,270 lines): Pydantic/SQLAlchemy models, polymorphic patterns - utils/ (959 lines): Logging, JWT auth, security - websocket/ (1,588 lines): Socket.io handlers, real-time events - tests/ (475 lines): Testing patterns and structure Each CLAUDE.md includes: - Purpose & architecture overview - Key components with detailed explanations - Patterns & conventions - Integration points - Common tasks (step-by-step guides) - Troubleshooting with solutions - Working code examples - Testing guidance Total changes: +9,294 lines / -24 lines Tests: All passing (62/62 model tests, 7/7 ManualOutcomeSubmission tests)
1289 lines
34 KiB
Markdown
1289 lines
34 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
|
|
```
|
|
|
|
**Groundball Integration**:
|
|
- Delegates all groundball 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, C)
|
|
- Lineouts
|
|
- Walks
|
|
- Singles (1, 2, uncapped)
|
|
- Doubles (2, 3, uncapped)
|
|
- Triples
|
|
- Home runs
|
|
- Wild pitch / Passed ball
|
|
|
|
---
|
|
|
|
### 4. runner_advancement.py
|
|
|
|
**Purpose**: Implements complete groundball runner advancement system
|
|
|
|
**Key Classes**:
|
|
- `RunnerAdvancement`: Main logic handler
|
|
- `GroundballResultType`: Enum of 13 result types (matches rulebook)
|
|
- `RunnerMovement`: Single runner's movement
|
|
- `AdvancementResult`: Complete result with all movements
|
|
|
|
**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()
|
|
```
|
|
|
|
**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
|
|
|
|
---
|
|
|
|
### 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
|
|
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
|
|
```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
|
|
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
|
|
pytest tests/unit/core/test_state_manager.py -v
|
|
|
|
# With coverage
|
|
pytest tests/unit/core/ --cov=app.core --cov-report=html
|
|
```
|
|
|
|
### Integration Tests
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```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 advancement (13 result types)
|
|
- `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
|