strat-gameplay-webapp/.claude/implementation/02-week5-game-logic.md
Cal Corum a287784328 CLAUDE: Complete Week 4 - State Management & Persistence
Implemented hybrid state management system with in-memory game states and async
PostgreSQL persistence. This provides the foundation for fast gameplay (<500ms
response) with complete state recovery capabilities.

## Components Implemented

### Production Code (3 files, 1,150 lines)
- app/models/game_models.py (492 lines)
  - Pydantic GameState with 20+ helper methods
  - RunnerState, LineupPlayerState, TeamLineupState
  - DefensiveDecision and OffensiveDecision models
  - Full Pydantic v2 validation with field validators

- app/core/state_manager.py (296 lines)
  - In-memory state management with O(1) lookups
  - State recovery from database
  - Idle game eviction mechanism
  - Statistics tracking

- app/database/operations.py (362 lines)
  - Async PostgreSQL operations
  - Game, lineup, and play persistence
  - Complete state loading for recovery
  - GameSession WebSocket state tracking

### Tests (4 files, 1,963 lines, 115 tests)
- tests/unit/models/test_game_models.py (60 tests, ALL PASSING)
- tests/unit/core/test_state_manager.py (26 tests, ALL PASSING)
- tests/integration/database/test_operations.py (21 tests)
- tests/integration/test_state_persistence.py (8 tests)
- pytest.ini (async test configuration)

### Documentation (6 files)
- backend/CLAUDE.md (updated with Week 4 patterns)
- .claude/implementation/02-week4-state-management.md (marked complete)
- .claude/status-2025-10-22-0113.md (planning session summary)
- .claude/status-2025-10-22-1147.md (implementation session summary)
- .claude/implementation/player-data-catalog.md (player data reference)
- Week 5 & 6 plans created

## Key Features

- Hybrid state: in-memory (fast) + PostgreSQL (persistent)
- O(1) state access via dictionary lookups
- Async database writes (non-blocking)
- Complete state recovery from database
- Pydantic validation on all models
- Helper methods for common game operations
- Idle game eviction with configurable timeout
- 86 unit tests passing (100%)

## Performance

- State access: O(1) via UUID lookup
- Memory per game: ~1KB (just state)
- Target response time: <500ms 
- Database writes: <100ms (async) 

## Testing

- Unit tests: 86/86 passing (100%)
- Integration tests: 29 written
- Test configuration: pytest.ini created
- Fixed Pydantic v2 config deprecation
- Fixed pytest-asyncio configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:01:03 -05:00

937 lines
29 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
---
## Overview
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
-`backend/app/core/dice.py` - Dice system
-`backend/app/core/play_resolver.py` - Play resolution
-`backend/app/core/validators.py` - Rule validation
-`backend/app/core/game_engine.py` - Game orchestration
### Tests
-`tests/unit/core/test_dice.py` - Dice distribution tests
-`tests/unit/core/test_play_resolver.py` - Resolution logic tests
-`tests/unit/core/test_validators.py` - Validation tests
-`tests/integration/test_game_engine.py` - Complete flow tests
-`tests/integration/test_complete_at_bat.py` - End-to-end at-bat
### 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
- [ ] Dice system produces uniform distribution over 1000+ rolls
- [ ] One complete at-bat executes successfully
- [ ] All state transitions validated
- [ ] Plays persist to database
- [ ] All tests pass
- [ ] Play resolution completes in <200ms
## Next Steps
After Week 5 completion, move to [Week 6: League Features & Integration](./02-week6-league-features.md)