1045 lines
35 KiB
Markdown
1045 lines
35 KiB
Markdown
# Week 5: Game Logic & Play Resolution
|
|
|
|
**Duration**: Week 5 of Phase 2
|
|
**Prerequisites**: Week 4 Complete (State Manager working)
|
|
**Focus**: Build game engine core with dice system and play resolution
|
|
**Status**: ✅ **COMPLETE** (2025-10-24)
|
|
|
|
---
|
|
|
|
## 🎯 Implementation Summary
|
|
|
|
Week 5 has been **successfully completed** with enhancements beyond the original plan:
|
|
|
|
### ✅ Completed (Enhanced)
|
|
- **Dice System**: Implemented with advanced `AbRoll` architecture (beyond simple d20)
|
|
- `roll_types.py` module for structured roll modeling
|
|
- Check rolls, resolution rolls, wild pitch/passed ball detection
|
|
- Batch persistence at inning boundaries
|
|
- **Play Resolver**: Working with simplified charts and wild pitch/passed ball outcomes
|
|
- **Game Engine**: Fully functional with forward-looking snapshot pattern (refactored 2025-10-25)
|
|
- **Validators**: Basic rule validation working
|
|
- **Manual Test Script**: Comprehensive `test_game_flow.py` with 5 test scenarios
|
|
|
|
### ✅ Testing Complete (2025-10-25)
|
|
- **Unit Tests**: `test_play_resolver.py` (18 tests) and `test_validators.py` (36 tests) created and passing
|
|
- **Integration Tests**: `test_game_engine.py` (7 test classes) created with comprehensive coverage
|
|
|
|
---
|
|
|
|
## Overview (Original Plan)
|
|
|
|
Implement the game simulation logic: cryptographic dice rolls, play resolution engine, game flow orchestration, and rule validation.
|
|
|
|
## Goals
|
|
|
|
By end of Week 5:
|
|
- ✅ Cryptographic dice system with d20 rolls
|
|
- ✅ Play resolver for SBA (simplified charts)
|
|
- ✅ Game engine coordinating turn flow
|
|
- ✅ Rule validators for game actions
|
|
- ✅ Complete ONE at-bat flow working end-to-end
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────┐
|
|
│ GameEngine │
|
|
│ ┌────────────┐ ┌─────────────┐ ┌──────────────┐ │
|
|
│ │ Validators │→ │ PlayResolver│→ │ StateManager │ │
|
|
│ └────────────┘ └─────────────┘ └──────────────┘ │
|
|
│ ↓ │
|
|
│ DiceSystem │
|
|
│ ↓ │
|
|
│ Cryptographic RNG │
|
|
└──────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Components to Build
|
|
|
|
### 1. Dice System (`backend/app/core/dice.py`)
|
|
|
|
Cryptographically secure dice rolling with logging.
|
|
|
|
```python
|
|
import logging
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
from typing import List
|
|
import pendulum
|
|
|
|
logger = logging.getLogger(f'{__name__}.DiceSystem')
|
|
|
|
|
|
@dataclass
|
|
class DiceRoll:
|
|
"""Result of a dice roll"""
|
|
roll: int # The primary d20 roll
|
|
modifiers: List[int] # Any modifier rolls
|
|
total: int # roll + sum(modifiers)
|
|
timestamp: pendulum.DateTime
|
|
roll_id: str # Unique identifier for verification
|
|
|
|
def __str__(self) -> str:
|
|
if self.modifiers:
|
|
mods = "+".join(str(m) for m in self.modifiers)
|
|
return f"{self.roll}+{mods}={self.total}"
|
|
return str(self.roll)
|
|
|
|
|
|
class DiceSystem:
|
|
"""Cryptographically secure dice rolling system"""
|
|
|
|
def __init__(self):
|
|
self._roll_history: List[DiceRoll] = []
|
|
|
|
def roll_d20(self) -> DiceRoll:
|
|
"""Roll a single d20"""
|
|
roll = secrets.randbelow(20) + 1 # 1-20
|
|
roll_result = DiceRoll(
|
|
roll=roll,
|
|
modifiers=[],
|
|
total=roll,
|
|
timestamp=pendulum.now('UTC'),
|
|
roll_id=secrets.token_hex(8)
|
|
)
|
|
|
|
self._roll_history.append(roll_result)
|
|
logger.info(f"Rolled d20: {roll} (ID: {roll_result.roll_id})")
|
|
|
|
return roll_result
|
|
|
|
def roll_d6(self) -> int:
|
|
"""Roll a single d6 (for modifiers/checks)"""
|
|
roll = secrets.randbelow(6) + 1
|
|
logger.debug(f"Rolled d6: {roll}")
|
|
return roll
|
|
|
|
def roll_with_modifier(self, modifier_dice: int = 0) -> DiceRoll:
|
|
"""
|
|
Roll d20 with additional modifier dice
|
|
|
|
Args:
|
|
modifier_dice: Number of d6 to add to roll
|
|
"""
|
|
base_roll = secrets.randbelow(20) + 1
|
|
modifiers = [self.roll_d6() for _ in range(modifier_dice)]
|
|
total = base_roll + sum(modifiers)
|
|
|
|
roll_result = DiceRoll(
|
|
roll=base_roll,
|
|
modifiers=modifiers,
|
|
total=total,
|
|
timestamp=pendulum.now('UTC'),
|
|
roll_id=secrets.token_hex(8)
|
|
)
|
|
|
|
self._roll_history.append(roll_result)
|
|
logger.info(f"Rolled with modifiers: {roll_result}")
|
|
|
|
return roll_result
|
|
|
|
def get_roll_history(self, limit: int = 100) -> List[DiceRoll]:
|
|
"""Get recent roll history"""
|
|
return self._roll_history[-limit:]
|
|
|
|
def verify_roll(self, roll_id: str) -> bool:
|
|
"""Verify a roll ID exists in history"""
|
|
return any(r.roll_id == roll_id for r in self._roll_history)
|
|
|
|
def get_distribution_stats(self) -> dict:
|
|
"""Get distribution statistics for testing"""
|
|
if not self._roll_history:
|
|
return {}
|
|
|
|
rolls = [r.roll for r in self._roll_history]
|
|
distribution = {i: rolls.count(i) for i in range(1, 21)}
|
|
|
|
return {
|
|
"total_rolls": len(rolls),
|
|
"distribution": distribution,
|
|
"average": sum(rolls) / len(rolls),
|
|
"min": min(rolls),
|
|
"max": max(rolls)
|
|
}
|
|
|
|
|
|
# Singleton instance
|
|
dice_system = DiceSystem()
|
|
```
|
|
|
|
**Implementation Steps:**
|
|
1. Create `backend/app/core/dice.py`
|
|
2. Implement DiceRoll dataclass
|
|
3. Implement DiceSystem with cryptographic RNG
|
|
4. Add roll history and verification
|
|
5. Write distribution tests
|
|
|
|
**Tests:**
|
|
- `tests/unit/core/test_dice.py`
|
|
- Test basic d20 roll (in range 1-20)
|
|
- Test roll history tracking
|
|
- Test roll verification
|
|
- Test distribution (run 1000+ rolls, verify roughly uniform)
|
|
|
|
---
|
|
|
|
### 2. Play Resolver (`backend/app/core/play_resolver.py`)
|
|
|
|
Resolves play outcomes based on dice rolls and decisions.
|
|
|
|
```python
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Optional, List
|
|
from enum import Enum
|
|
|
|
from app.core.dice import DiceSystem, DiceRoll
|
|
from app.models.game_models import GameState, RunnerState, DefensiveDecision, OffensiveDecision
|
|
|
|
logger = logging.getLogger(f'{__name__}.PlayResolver')
|
|
|
|
|
|
class PlayOutcome(str, Enum):
|
|
"""Possible play outcomes"""
|
|
# Outs
|
|
STRIKEOUT = "strikeout"
|
|
GROUNDOUT = "groundout"
|
|
FLYOUT = "flyout"
|
|
LINEOUT = "lineout"
|
|
DOUBLE_PLAY = "double_play"
|
|
|
|
# Hits
|
|
SINGLE = "single"
|
|
DOUBLE = "double"
|
|
TRIPLE = "triple"
|
|
HOMERUN = "homerun"
|
|
|
|
# Other
|
|
WALK = "walk"
|
|
HIT_BY_PITCH = "hbp"
|
|
ERROR = "error"
|
|
|
|
|
|
@dataclass
|
|
class PlayResult:
|
|
"""Result of a resolved play"""
|
|
outcome: PlayOutcome
|
|
outs_recorded: int
|
|
runs_scored: int
|
|
batter_result: Optional[int] # None = out, 1-4 = base reached
|
|
runners_advanced: List[tuple[int, int]] # [(from_base, to_base), ...]
|
|
description: str
|
|
dice_roll: DiceRoll
|
|
|
|
# Statistics
|
|
is_hit: bool = False
|
|
is_out: bool = False
|
|
is_walk: bool = False
|
|
|
|
|
|
class SimplifiedResultChart:
|
|
"""
|
|
Simplified SBA result chart for Phase 2
|
|
|
|
Real implementation will load from config files.
|
|
This placeholder provides basic outcomes for testing.
|
|
"""
|
|
|
|
@staticmethod
|
|
def get_outcome(roll: int) -> PlayOutcome:
|
|
"""
|
|
Map d20 roll to outcome (simplified)
|
|
|
|
Real chart will consider:
|
|
- Batter card stats
|
|
- Pitcher card stats
|
|
- Defensive alignment
|
|
- Offensive approach
|
|
"""
|
|
if roll <= 5:
|
|
return PlayOutcome.STRIKEOUT
|
|
elif roll <= 10:
|
|
return PlayOutcome.GROUNDOUT
|
|
elif roll <= 13:
|
|
return PlayOutcome.FLYOUT
|
|
elif roll <= 15:
|
|
return PlayOutcome.WALK
|
|
elif roll <= 17:
|
|
return PlayOutcome.SINGLE
|
|
elif roll <= 18:
|
|
return PlayOutcome.DOUBLE
|
|
elif roll == 19:
|
|
return PlayOutcome.TRIPLE
|
|
else: # 20
|
|
return PlayOutcome.HOMERUN
|
|
|
|
|
|
class PlayResolver:
|
|
"""Resolves play outcomes based on dice rolls and game state"""
|
|
|
|
def __init__(self):
|
|
self.dice = DiceSystem()
|
|
self.result_chart = SimplifiedResultChart()
|
|
|
|
def resolve_play(
|
|
self,
|
|
state: GameState,
|
|
defensive_decision: DefensiveDecision,
|
|
offensive_decision: OffensiveDecision
|
|
) -> PlayResult:
|
|
"""
|
|
Resolve a complete play
|
|
|
|
Args:
|
|
state: Current game state
|
|
defensive_decision: Defensive team's choices
|
|
offensive_decision: Offensive team's choices
|
|
|
|
Returns:
|
|
PlayResult with complete outcome
|
|
"""
|
|
logger.info(f"Resolving play - Inning {state.inning} {state.half}, {state.outs} outs")
|
|
|
|
# Roll dice
|
|
dice_roll = self.dice.roll_d20()
|
|
logger.info(f"Dice roll: {dice_roll.roll}")
|
|
|
|
# Get base outcome from chart
|
|
outcome = self.result_chart.get_outcome(dice_roll.roll)
|
|
logger.info(f"Base outcome: {outcome}")
|
|
|
|
# Apply decisions (simplified for Phase 2)
|
|
# TODO: Implement full decision logic in Phase 3
|
|
|
|
# Resolve outcome details
|
|
result = self._resolve_outcome(outcome, state, dice_roll)
|
|
|
|
logger.info(f"Play result: {result.description}")
|
|
return result
|
|
|
|
def _resolve_outcome(
|
|
self,
|
|
outcome: PlayOutcome,
|
|
state: GameState,
|
|
dice_roll: DiceRoll
|
|
) -> PlayResult:
|
|
"""Resolve specific outcome type"""
|
|
|
|
if outcome == PlayOutcome.STRIKEOUT:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Strikeout looking",
|
|
dice_roll=dice_roll,
|
|
is_out=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.GROUNDOUT:
|
|
# Simple groundout - runners don't advance
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Groundout to shortstop",
|
|
dice_roll=dice_roll,
|
|
is_out=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.FLYOUT:
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=1,
|
|
runs_scored=0,
|
|
batter_result=None,
|
|
runners_advanced=[],
|
|
description="Flyout to center field",
|
|
dice_roll=dice_roll,
|
|
is_out=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.WALK:
|
|
# Walk - batter to first, runners advance if forced
|
|
runners_advanced = self._advance_on_walk(state)
|
|
runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=1,
|
|
runners_advanced=runners_advanced,
|
|
description="Walk",
|
|
dice_roll=dice_roll,
|
|
is_walk=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.SINGLE:
|
|
# Single - batter to first, runners advance 1-2 bases
|
|
runners_advanced = self._advance_on_single(state)
|
|
runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=1,
|
|
runners_advanced=runners_advanced,
|
|
description="Single to left field",
|
|
dice_roll=dice_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.DOUBLE:
|
|
runners_advanced = self._advance_on_double(state)
|
|
runs_scored = sum(1 for (from_base, to_base) in runners_advanced if to_base == 4)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=2,
|
|
runners_advanced=runners_advanced,
|
|
description="Double to right-center",
|
|
dice_roll=dice_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.TRIPLE:
|
|
# All runners score
|
|
runs_scored = len(state.runners)
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=3,
|
|
runners_advanced=[(r.on_base, 4) for r in state.runners],
|
|
description="Triple to right-center gap",
|
|
dice_roll=dice_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
elif outcome == PlayOutcome.HOMERUN:
|
|
# Everyone scores
|
|
runs_scored = len(state.runners) + 1
|
|
|
|
return PlayResult(
|
|
outcome=outcome,
|
|
outs_recorded=0,
|
|
runs_scored=runs_scored,
|
|
batter_result=4,
|
|
runners_advanced=[(r.on_base, 4) for r in state.runners],
|
|
description="Home run to left field",
|
|
dice_roll=dice_roll,
|
|
is_hit=True
|
|
)
|
|
|
|
else:
|
|
raise ValueError(f"Unhandled outcome: {outcome}")
|
|
|
|
def _advance_on_walk(self, state: GameState) -> List[tuple[int, int]]:
|
|
"""Calculate runner advancement on walk"""
|
|
advances = []
|
|
|
|
# Only forced runners advance
|
|
if any(r.on_base == 1 for r in state.runners):
|
|
# First occupied - check second
|
|
if any(r.on_base == 2 for r in state.runners):
|
|
# Bases loaded scenario
|
|
if any(r.on_base == 3 for r in state.runners):
|
|
# Bases loaded - force runner home
|
|
advances.append((3, 4))
|
|
advances.append((2, 3))
|
|
advances.append((1, 2))
|
|
|
|
return advances
|
|
|
|
def _advance_on_single(self, state: GameState) -> List[tuple[int, int]]:
|
|
"""Calculate runner advancement on single (simplified)"""
|
|
advances = []
|
|
|
|
for runner in state.runners:
|
|
if runner.on_base == 3:
|
|
# Runner on third scores
|
|
advances.append((3, 4))
|
|
elif runner.on_base == 2:
|
|
# Runner on second scores (simplified - usually would)
|
|
advances.append((2, 4))
|
|
elif runner.on_base == 1:
|
|
# Runner on first to third (simplified)
|
|
advances.append((1, 3))
|
|
|
|
return advances
|
|
|
|
def _advance_on_double(self, state: GameState) -> List[tuple[int, int]]:
|
|
"""Calculate runner advancement on double"""
|
|
advances = []
|
|
|
|
for runner in state.runners:
|
|
# All runners score on double (simplified)
|
|
advances.append((runner.on_base, 4))
|
|
|
|
return advances
|
|
|
|
|
|
# Singleton instance
|
|
play_resolver = PlayResolver()
|
|
```
|
|
|
|
**Implementation Steps:**
|
|
1. Create `backend/app/core/play_resolver.py`
|
|
2. Implement simplified result chart
|
|
3. Implement play resolution logic
|
|
4. Add runner advancement logic
|
|
5. Write unit tests
|
|
|
|
**Tests:**
|
|
- `tests/unit/core/test_play_resolver.py`
|
|
- Test each outcome type
|
|
- Test runner advancement logic
|
|
- Test run scoring
|
|
- Mock dice rolls for deterministic testing
|
|
|
|
---
|
|
|
|
### 3. Rule Validators (`backend/app/core/validators.py`)
|
|
|
|
Validate game actions and state transitions.
|
|
|
|
```python
|
|
import logging
|
|
from typing import Optional
|
|
from uuid import UUID
|
|
|
|
from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision
|
|
|
|
logger = logging.getLogger(f'{__name__}.Validators')
|
|
|
|
|
|
class ValidationError(Exception):
|
|
"""Raised when validation fails"""
|
|
pass
|
|
|
|
|
|
class GameValidator:
|
|
"""Validates game actions and state"""
|
|
|
|
@staticmethod
|
|
def validate_game_active(state: GameState) -> None:
|
|
"""Ensure game is in active state"""
|
|
if state.status != "active":
|
|
raise ValidationError(f"Game is not active (status: {state.status})")
|
|
|
|
@staticmethod
|
|
def validate_outs(outs: int) -> None:
|
|
"""Ensure outs are valid"""
|
|
if outs < 0 or outs > 2:
|
|
raise ValidationError(f"Invalid outs: {outs} (must be 0-2)")
|
|
|
|
@staticmethod
|
|
def validate_inning(inning: int, half: str) -> None:
|
|
"""Ensure inning is valid"""
|
|
if inning < 1:
|
|
raise ValidationError(f"Invalid inning: {inning}")
|
|
if half not in ["top", "bottom"]:
|
|
raise ValidationError(f"Invalid half: {half}")
|
|
|
|
@staticmethod
|
|
def validate_defensive_decision(decision: DefensiveDecision, state: GameState) -> None:
|
|
"""Validate defensive team decision"""
|
|
valid_alignments = ["normal", "shifted_left", "shifted_right"]
|
|
if decision.alignment not in valid_alignments:
|
|
raise ValidationError(f"Invalid alignment: {decision.alignment}")
|
|
|
|
valid_depths = ["in", "normal", "back", "double_play"]
|
|
if decision.infield_depth not in valid_depths:
|
|
raise ValidationError(f"Invalid infield depth: {decision.infield_depth}")
|
|
|
|
# Validate hold runners - can't hold empty bases
|
|
runner_bases = [r.on_base for r in state.runners]
|
|
for base in decision.hold_runners:
|
|
if base not in runner_bases:
|
|
raise ValidationError(f"Can't hold base {base} - no runner present")
|
|
|
|
logger.debug("Defensive decision validated")
|
|
|
|
@staticmethod
|
|
def validate_offensive_decision(decision: OffensiveDecision, state: GameState) -> None:
|
|
"""Validate offensive team decision"""
|
|
valid_approaches = ["normal", "contact", "power", "patient"]
|
|
if decision.approach not in valid_approaches:
|
|
raise ValidationError(f"Invalid approach: {decision.approach}")
|
|
|
|
# Validate steal attempts
|
|
runner_bases = [r.on_base for r in state.runners]
|
|
for base in decision.steal_attempts:
|
|
# Must have runner on base-1 to steal base
|
|
if (base - 1) not in runner_bases:
|
|
raise ValidationError(f"Can't steal {base} - no runner on {base-1}")
|
|
|
|
# Can't bunt with 2 outs (simplified rule)
|
|
if decision.bunt_attempt and state.outs == 2:
|
|
raise ValidationError("Cannot bunt with 2 outs")
|
|
|
|
logger.debug("Offensive decision validated")
|
|
|
|
@staticmethod
|
|
def can_continue_inning(state: GameState) -> bool:
|
|
"""Check if inning can continue"""
|
|
return state.outs < 3
|
|
|
|
@staticmethod
|
|
def is_game_over(state: GameState) -> bool:
|
|
"""Check if game is complete"""
|
|
# Game over after 9 innings if score not tied
|
|
if state.inning >= 9 and state.half == "bottom":
|
|
if state.home_score != state.away_score:
|
|
return True
|
|
return False
|
|
|
|
|
|
# Singleton instance
|
|
game_validator = GameValidator()
|
|
```
|
|
|
|
**Tests:**
|
|
- `tests/unit/core/test_validators.py`
|
|
- Test validation failures
|
|
- Test edge cases
|
|
|
|
---
|
|
|
|
### 4. Game Engine (`backend/app/core/game_engine.py`)
|
|
|
|
Orchestrates game flow and coordinates all components.
|
|
|
|
```python
|
|
import logging
|
|
from uuid import UUID
|
|
from typing import Optional
|
|
|
|
from app.core.state_manager import state_manager
|
|
from app.core.play_resolver import play_resolver, PlayResult
|
|
from app.core.validators import game_validator, ValidationError
|
|
from app.database.operations import DatabaseOperations
|
|
from app.models.game_models import (
|
|
GameState, RunnerState, DefensiveDecision, OffensiveDecision
|
|
)
|
|
|
|
logger = logging.getLogger(f'{__name__}.GameEngine')
|
|
|
|
|
|
class GameEngine:
|
|
"""Main game orchestration engine"""
|
|
|
|
def __init__(self):
|
|
self.db_ops = DatabaseOperations()
|
|
|
|
async def start_game(self, game_id: UUID) -> GameState:
|
|
"""
|
|
Start a game
|
|
|
|
Transitions from 'pending' to 'active'
|
|
"""
|
|
state = state_manager.get_state(game_id)
|
|
if not state:
|
|
raise ValueError(f"Game {game_id} not found in state manager")
|
|
|
|
if state.status != "pending":
|
|
raise ValidationError(f"Game already started (status: {state.status})")
|
|
|
|
# Mark as active
|
|
state.status = "active"
|
|
state.inning = 1
|
|
state.half = "top"
|
|
state.outs = 0
|
|
|
|
# Update state
|
|
state_manager.update_state(game_id, state)
|
|
|
|
# Persist to DB
|
|
await self.db_ops.update_game_state(
|
|
game_id=game_id,
|
|
inning=1,
|
|
half="top",
|
|
home_score=0,
|
|
away_score=0
|
|
)
|
|
|
|
logger.info(f"Started game {game_id}")
|
|
return state
|
|
|
|
async def submit_defensive_decision(
|
|
self,
|
|
game_id: UUID,
|
|
decision: DefensiveDecision
|
|
) -> GameState:
|
|
"""Submit defensive team decision"""
|
|
state = state_manager.get_state(game_id)
|
|
if not state:
|
|
raise ValueError(f"Game {game_id} not found")
|
|
|
|
game_validator.validate_game_active(state)
|
|
game_validator.validate_defensive_decision(decision, state)
|
|
|
|
# Store decision
|
|
state.decisions_this_play['defensive'] = decision.dict()
|
|
state.pending_decision = "offensive"
|
|
|
|
state_manager.update_state(game_id, state)
|
|
logger.info(f"Defensive decision submitted for game {game_id}")
|
|
|
|
return state
|
|
|
|
async def submit_offensive_decision(
|
|
self,
|
|
game_id: UUID,
|
|
decision: OffensiveDecision
|
|
) -> GameState:
|
|
"""Submit offensive team decision"""
|
|
state = state_manager.get_state(game_id)
|
|
if not state:
|
|
raise ValueError(f"Game {game_id} not found")
|
|
|
|
game_validator.validate_game_active(state)
|
|
game_validator.validate_offensive_decision(decision, state)
|
|
|
|
# Store decision
|
|
state.decisions_this_play['offensive'] = decision.dict()
|
|
state.pending_decision = "resolution"
|
|
|
|
state_manager.update_state(game_id, state)
|
|
logger.info(f"Offensive decision submitted for game {game_id}")
|
|
|
|
return state
|
|
|
|
async def resolve_play(self, game_id: UUID) -> PlayResult:
|
|
"""
|
|
Resolve the current play with dice roll
|
|
|
|
This is the core game logic execution.
|
|
"""
|
|
state = state_manager.get_state(game_id)
|
|
if not state:
|
|
raise ValueError(f"Game {game_id} not found")
|
|
|
|
game_validator.validate_game_active(state)
|
|
|
|
# Get decisions
|
|
defensive_decision = DefensiveDecision(**state.decisions_this_play.get('defensive', {}))
|
|
offensive_decision = OffensiveDecision(**state.decisions_this_play.get('offensive', {}))
|
|
|
|
# Resolve play
|
|
result = play_resolver.resolve_play(state, defensive_decision, offensive_decision)
|
|
|
|
# Apply result to state
|
|
await self._apply_play_result(state, result)
|
|
|
|
# Clear decisions for next play
|
|
state.decisions_this_play = {}
|
|
state.pending_decision = "defensive"
|
|
|
|
state_manager.update_state(game_id, state)
|
|
|
|
logger.info(f"Resolved play {state.play_count} for game {game_id}: {result.description}")
|
|
return result
|
|
|
|
async def _apply_play_result(self, state: GameState, result: PlayResult) -> None:
|
|
"""Apply play result to game state"""
|
|
|
|
# Update outs
|
|
state.outs += result.outs_recorded
|
|
|
|
# Update runners
|
|
new_runners = []
|
|
|
|
# Advance existing runners
|
|
for runner in state.runners:
|
|
for from_base, to_base in result.runners_advanced:
|
|
if runner.on_base == from_base:
|
|
if to_base < 4: # Not scored
|
|
runner.on_base = to_base
|
|
new_runners.append(runner)
|
|
break
|
|
else:
|
|
# Runner not in advancement list - stays put
|
|
new_runners.append(runner)
|
|
|
|
# Add batter if reached base
|
|
if result.batter_result and result.batter_result < 4:
|
|
# TODO: Get actual batter lineup_id and card_id
|
|
new_runners.append(RunnerState(
|
|
lineup_id=0, # Placeholder
|
|
card_id=0, # Placeholder
|
|
on_base=result.batter_result
|
|
))
|
|
|
|
state.runners = new_runners
|
|
|
|
# Update score
|
|
if state.half == "top":
|
|
state.away_score += result.runs_scored
|
|
else:
|
|
state.home_score += result.runs_scored
|
|
|
|
# Increment play count
|
|
state.play_count += 1
|
|
state.last_play_result = result.description
|
|
|
|
# Check if inning is over
|
|
if state.outs >= 3:
|
|
await self._advance_inning(state)
|
|
|
|
# Persist play to database
|
|
await self._save_play_to_db(state, result)
|
|
|
|
# Update game state in DB
|
|
await self.db_ops.update_game_state(
|
|
game_id=state.game_id,
|
|
inning=state.inning,
|
|
half=state.half,
|
|
home_score=state.home_score,
|
|
away_score=state.away_score
|
|
)
|
|
|
|
async def _advance_inning(self, state: GameState) -> None:
|
|
"""Advance to next half inning"""
|
|
if state.half == "top":
|
|
state.half = "bottom"
|
|
else:
|
|
state.half = "top"
|
|
state.inning += 1
|
|
|
|
state.outs = 0
|
|
state.runners = []
|
|
state.current_batter_idx = 0
|
|
|
|
logger.info(f"Advanced to inning {state.inning} {state.half}")
|
|
|
|
# Check if game is over
|
|
if game_validator.is_game_over(state):
|
|
state.status = "completed"
|
|
logger.info(f"Game {state.game_id} completed")
|
|
|
|
async def _save_play_to_db(self, state: GameState, result: PlayResult) -> None:
|
|
"""Save play to database"""
|
|
play_data = {
|
|
"game_id": state.game_id,
|
|
"play_number": state.play_count,
|
|
"inning": state.inning,
|
|
"half": state.half,
|
|
"outs_before": state.outs - result.outs_recorded,
|
|
"outs_recorded": result.outs_recorded,
|
|
"batter_id": 1, # Placeholder
|
|
"pitcher_id": 1, # Placeholder
|
|
"catcher_id": 1, # Placeholder
|
|
"dice_roll": str(result.dice_roll),
|
|
"hit_type": result.outcome.value,
|
|
"result_description": result.description,
|
|
"runs_scored": result.runs_scored,
|
|
"away_score": state.away_score,
|
|
"home_score": state.home_score,
|
|
"complete": True
|
|
}
|
|
|
|
await self.db_ops.save_play(play_data)
|
|
|
|
|
|
# Singleton instance
|
|
game_engine = GameEngine()
|
|
```
|
|
|
|
**Implementation Steps:**
|
|
1. Create `backend/app/core/game_engine.py`
|
|
2. Implement game start flow
|
|
3. Implement decision submission
|
|
4. Implement play resolution
|
|
5. Write integration tests
|
|
|
|
**Tests:**
|
|
- `tests/integration/test_game_engine.py`
|
|
- Test complete at-bat flow
|
|
- Test inning advancement
|
|
- Test score tracking
|
|
|
|
---
|
|
|
|
## Week 5 Deliverables
|
|
|
|
### Code Files (Actual Implementation)
|
|
- ✅ `backend/app/core/dice.py` - Dice system with batch persistence
|
|
- ✅ `backend/app/core/roll_types.py` - **BONUS**: Structured roll modeling (AbRoll, CheckRoll, etc.)
|
|
- ✅ `backend/app/core/play_resolver.py` - Play resolution with wild pitch/passed ball
|
|
- ✅ `backend/app/core/validators.py` - Rule validation with lineup checks
|
|
- ✅ `backend/app/core/game_engine.py` - Game orchestration with forward-looking snapshots
|
|
|
|
### Tests (Actual Status)
|
|
- ✅ `tests/unit/core/test_dice.py` - Dice distribution tests **COMPLETE** (from initial implementation)
|
|
- ✅ `tests/unit/core/test_roll_types.py` - **BONUS**: Roll type tests **COMPLETE** (from initial implementation)
|
|
- ✅ `tests/unit/core/test_play_resolver.py` - **COMPLETE** (18 tests, created 2025-10-25)
|
|
- ✅ `tests/unit/core/test_validators.py` - **COMPLETE** (36 tests, created 2025-10-25)
|
|
- ✅ `tests/integration/test_game_engine.py` - **COMPLETE** (7 test classes, created 2025-10-25)
|
|
- ✅ `scripts/test_game_flow.py` - **BONUS**: Manual test script **WORKING** (for manual validation)
|
|
|
|
### Test Script
|
|
Create `scripts/test_game_flow.py` for manual testing:
|
|
|
|
```python
|
|
"""Test script to simulate a complete at-bat"""
|
|
import asyncio
|
|
from uuid import uuid4
|
|
|
|
from app.core.state_manager import state_manager
|
|
from app.core.game_engine import game_engine
|
|
from app.models.game_models import DefensiveDecision, OffensiveDecision
|
|
|
|
|
|
async def test_at_bat():
|
|
"""Simulate one complete at-bat"""
|
|
|
|
# Create game
|
|
game_id = uuid4()
|
|
state = await state_manager.create_game(
|
|
game_id=game_id,
|
|
league_id="sba",
|
|
home_team_id=1,
|
|
away_team_id=2
|
|
)
|
|
|
|
print(f"Created game {game_id}")
|
|
|
|
# Start game
|
|
state = await game_engine.start_game(game_id)
|
|
print(f"Game started - Inning {state.inning} {state.half}")
|
|
|
|
# Defensive decision
|
|
def_decision = DefensiveDecision(alignment="normal")
|
|
await game_engine.submit_defensive_decision(game_id, def_decision)
|
|
print("Defensive decision submitted")
|
|
|
|
# Offensive decision
|
|
off_decision = OffensiveDecision(approach="normal")
|
|
await game_engine.submit_offensive_decision(game_id, off_decision)
|
|
print("Offensive decision submitted")
|
|
|
|
# Resolve play
|
|
result = await game_engine.resolve_play(game_id)
|
|
print(f"Play resolved: {result.description}")
|
|
print(f"Dice: {result.dice_roll}")
|
|
print(f"Outs: {result.outs_recorded}, Runs: {result.runs_scored}")
|
|
|
|
# Check final state
|
|
final_state = state_manager.get_state(game_id)
|
|
print(f"\nFinal state:")
|
|
print(f" Outs: {final_state.outs}")
|
|
print(f" Score: Away {final_state.away_score} - Home {final_state.home_score}")
|
|
print(f" Runners: {len(final_state.runners)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(test_at_bat())
|
|
```
|
|
|
|
## Success Criteria (Actual Results)
|
|
|
|
- ✅ Dice system produces uniform distribution over 1000+ rolls (verified in tests)
|
|
- ✅ One complete at-bat executes successfully (manual test passes + integration tests)
|
|
- ✅ All state transitions validated (validators working + 36 validator tests)
|
|
- ✅ Plays persist to database (with snapshots and roll batching)
|
|
- ✅ **COMPLETE**: All tests pass (18 play_resolver + 36 validator + 7 integration test classes)
|
|
- ✅ Play resolution completes in <200ms (fast in-memory operations)
|
|
|
|
### Test Coverage Summary
|
|
- **Unit Tests**: 54 tests covering dice, roll types, play resolution, and validation
|
|
- **Integration Tests**: 7 test classes covering complete game flows (requires database)
|
|
- **Manual Test Script**: 5 comprehensive test scenarios for manual validation
|
|
|
|
## Enhancements Beyond Plan
|
|
|
|
### 1. Advanced Dice System (AbRoll)
|
|
The implemented dice system is more sophisticated than planned:
|
|
- **Structured Roll Types**: `AbRoll`, `CheckRoll`, `ResolutionRoll` dataclasses
|
|
- **Context Tracking**: Each roll knows its game_id, inning, play_number
|
|
- **Batch Persistence**: Rolls saved at inning boundaries instead of per-play
|
|
- **Wild Pitch/Passed Ball**: Special roll detection on check_d20 == 1 or 2
|
|
|
|
### 2. Forward-Looking Snapshot Pattern (Refactor 2025-10-25)
|
|
The GameEngine uses a sophisticated snapshot pattern:
|
|
- **Prepare Before Execute**: `_prepare_next_play()` sets snapshot fields before play resolution
|
|
- **Independent Batting Orders**: `away_team_batter_idx` and `home_team_batter_idx` track separately
|
|
- **Lineup Validation**: At game start and inning changes, defensive positions validated
|
|
- **On-Base Code**: Bit field (1=1st, 2=2nd, 4=3rd) calculated from runners
|
|
|
|
### 3. Database-Driven Lineup Management
|
|
Unlike the simple placeholder approach in the plan:
|
|
- Lineups fetched from database via `get_active_lineup()`
|
|
- Snapshot fields reference actual lineup IDs from database
|
|
- Supports future substitution tracking
|
|
|
|
## Test Implementation Details (2025-10-25)
|
|
|
|
### test_play_resolver.py (18 tests)
|
|
**Coverage**:
|
|
- `TestSimplifiedResultChart` (12 tests): All outcome ranges (strikeout, groundout, flyout, walk, single, double, triple, homerun) + wild pitch/passed ball confirmation logic
|
|
- `TestPlayResultResolution` (5 tests): Outcome resolution with runner advancement (walk, single, homerun, wild pitch scenarios)
|
|
- `TestPlayResolverSingleton` (1 test): Singleton pattern validation
|
|
|
|
**Key Insights**:
|
|
- Tests use mock `AbRoll` objects with simplified constructor (no CheckRoll/ResolutionRoll sub-objects)
|
|
- Wild pitch/passed ball confirmation tested (check_d20 triggers, resolution_d20 confirms)
|
|
- Runner advancement logic validated for bases loaded, scoring from third, grand slams
|
|
|
|
### test_validators.py (36 tests)
|
|
**Coverage**:
|
|
- `TestGameStateValidation` (3 tests): Active/pending/completed state checks
|
|
- `TestOutsValidation` (3 tests): Valid range (0-2), negative, too high
|
|
- `TestInningValidation` (5 tests): Valid innings, zero/negative, invalid half values
|
|
- `TestDefensiveDecisionValidation` (5 tests): Valid decisions, Pydantic validation of alignment/depth, hold runner logic
|
|
- `TestOffensiveDecisionValidation` (6 tests): Valid decisions, Pydantic validation of approach, steal validation, bunt with 2 outs rule
|
|
- `TestLineupValidation` (5 tests): Complete lineup, missing positions, duplicates, inactive players, DH optional
|
|
- `TestGameFlowValidation` (8 tests): Inning continuation, game over conditions (9th inning, extras, tied games)
|
|
- `TestGameValidatorSingleton` (1 test): Singleton pattern validation
|
|
|
|
**Key Insights**:
|
|
- Discovered that Pydantic validates at model creation, not assignment (unless `validate_assignment=True`)
|
|
- Tests properly simulate `state.outs += 1` (goes to 3 temporarily) to match GameEngine flow
|
|
- Confirmed lineup validation enforces exactly one active player per required position (P, C, 1B, 2B, 3B, SS, LF, CF, RF)
|
|
|
|
### test_game_engine.py (7 test classes)
|
|
**Coverage**:
|
|
- `TestSingleAtBat`: Complete at-bat flow (create → start → decisions → resolve)
|
|
- `TestFullInning`: Play until 3 outs, verify inning advancement
|
|
- `TestLineupValidation`: Fail cases (no lineups, incomplete, missing positions)
|
|
- `TestSnapshotTracking`: Verify snapshot fields populated, on_base_code calculation
|
|
- `TestBattingOrderCycling`: Independent batting order per team, wraparound at 9
|
|
- `TestGameCompletion`: Game status changes to completed at end
|
|
|
|
**Key Insights**:
|
|
- All tests require database access (marked with `@pytest.mark.integration`)
|
|
- Tests create full lineups (9 players per team) for realistic scenarios
|
|
- Validates forward-looking snapshot pattern works end-to-end
|
|
|
|
## Week 5 Status: ✅ COMPLETE
|
|
|
|
**Completion Date**: 2025-10-25
|
|
|
|
All deliverables achieved:
|
|
- ✅ Code implementation (dice, play_resolver, validators, game_engine)
|
|
- ✅ Unit tests (54 tests passing)
|
|
- ✅ Integration tests (7 test classes)
|
|
- ✅ Manual test script (5 scenarios)
|
|
- ✅ Documentation updated
|
|
|
|
## Next Steps
|
|
|
|
**Proceed to Week 6**: [Week 6: League Features & Integration](./02-week6-league-features.md)
|
|
- League configuration system (SBA and PD configs)
|
|
- Complete result charts (beyond simplified charts)
|
|
- API client integration
|
|
- End-to-end testing with real league data |