strat-gameplay-webapp/.claude/archive/02-week5-game-logic.md
Cal Corum c4e051c4a9 Documentation Archival
Moving unneeded notes to archive directory.
2025-11-01 01:17:15 -05:00

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

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 (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, 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

  • League configuration system (SBA and PD configs)
  • Complete result charts (beyond simplified charts)
  • API client integration
  • End-to-end testing with real league data