35 KiB
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
AbRollarchitecture (beyond simple d20)roll_types.pymodule 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.pywith 5 test scenarios
✅ Testing Complete (2025-10-25)
- Unit Tests:
test_play_resolver.py(18 tests) andtest_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.
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:
- Create
backend/app/core/dice.py - Implement DiceRoll dataclass
- Implement DiceSystem with cryptographic RNG
- Add roll history and verification
- 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.
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:
- Create
backend/app/core/play_resolver.py - Implement simplified result chart
- Implement play resolution logic
- Add runner advancement logic
- 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.
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.
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:
- Create
backend/app/core/game_engine.py - Implement game start flow
- Implement decision submission
- Implement play resolution
- 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:
"""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,ResolutionRolldataclasses - 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_idxandhome_team_batter_idxtrack 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 logicTestPlayResultResolution(5 tests): Outcome resolution with runner advancement (walk, single, homerun, wild pitch scenarios)TestPlayResolverSingleton(1 test): Singleton pattern validation
Key Insights:
- Tests use mock
AbRollobjects 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 checksTestOutsValidation(3 tests): Valid range (0-2), negative, too highTestInningValidation(5 tests): Valid innings, zero/negative, invalid half valuesTestDefensiveDecisionValidation(5 tests): Valid decisions, Pydantic validation of alignment/depth, hold runner logicTestOffensiveDecisionValidation(6 tests): Valid decisions, Pydantic validation of approach, steal validation, bunt with 2 outs ruleTestLineupValidation(5 tests): Complete lineup, missing positions, duplicates, inactive players, DH optionalTestGameFlowValidation(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 advancementTestLineupValidation: Fail cases (no lineups, incomplete, missing positions)TestSnapshotTracking: Verify snapshot fields populated, on_base_code calculationTestBattingOrderCycling: Independent batting order per team, wraparound at 9TestGameCompletion: 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
- League configuration system (SBA and PD configs)
- Complete result charts (beyond simplified charts)
- API client integration
- End-to-end testing with real league data