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

29 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


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.

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.

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.

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:

  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:

"""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