""" Cryptographically Secure Dice Rolling System Implements secure random number generation for baseball gameplay with support for all roll types: at-bat, jump, fielding, and generic d20. """ import logging import secrets from uuid import UUID import pendulum from app.core.roll_types import ( AbRoll, D20Roll, DiceRoll, FieldingRoll, JumpRoll, RollType, ) logger = logging.getLogger(f"{__name__}.DiceSystem") class DiceSystem: """ Cryptographically secure dice rolling system for baseball gameplay Uses Python's secrets module for cryptographic randomness. Maintains roll history for auditing and game recovery. """ def __init__(self): self._roll_history: list[DiceRoll] = [] def _generate_roll_id(self) -> str: """Generate unique cryptographic roll ID""" return secrets.token_hex(8) def _roll_d6(self) -> int: """Roll single d6 (1-6)""" return secrets.randbelow(6) + 1 def _roll_d20(self) -> int: """Roll single d20 (1-20)""" return secrets.randbelow(20) + 1 def _roll_d100(self) -> int: """Roll single d100 (1-100)""" return secrets.randbelow(100) + 1 def roll_ab( self, league_id: str, game_id: UUID | None = None, team_id: int | None = None, player_id: int | None = None, runners_on_base: bool = True, ) -> AbRoll: """ Roll at-bat dice: 1d6 + 2d6 + 2d20 Always rolls all dice. The chaos_d20 determines usage: - chaos_d20 == 1: 5% chance - Wild pitch check (use resolution_d20 for confirmation) - chaos_d20 == 2: 5% chance - Passed ball check (use resolution_d20 for confirmation) - chaos_d20 >= 3: Normal at-bat (use chaos_d20 for result, resolution_d20 for splits) If runners_on_base is False, the chaos check is skipped (WP/PB meaningless without runners). Args: league_id: 'sba' or 'pd' game_id: Optional UUID of game in progress team_id: Optional team ID for auditing player_id: Optional player/card ID for auditing (polymorphic) runners_on_base: Whether there are runners on base (affects chaos check) Returns: AbRoll with all dice results """ d6_one = self._roll_d6() d6_two_a = self._roll_d6() d6_two_b = self._roll_d6() chaos_d20 = self._roll_d20() resolution_d20 = self._roll_d20() # Always roll, used for WP/PB or splits # Skip chaos check if no runners on base (WP/PB is meaningless) chaos_check_skipped = not runners_on_base roll = AbRoll( roll_id=self._generate_roll_id(), roll_type=RollType.AB, league_id=league_id, timestamp=pendulum.now("UTC"), game_id=game_id, team_id=team_id, player_id=player_id, d6_one=d6_one, d6_two_a=d6_two_a, d6_two_b=d6_two_b, chaos_d20=chaos_d20, resolution_d20=resolution_d20, d6_two_total=0, # Calculated in __post_init__ check_wild_pitch=False, # Calculated in __post_init__ check_passed_ball=False, # Calculated in __post_init__ chaos_check_skipped=chaos_check_skipped, ) self._roll_history.append(roll) logger.info( f"AB roll: {roll}", extra={ "roll_id": roll.roll_id, "game_id": str(game_id) if game_id else None, }, ) return roll def roll_jump( self, league_id: str, game_id: UUID | None = None, team_id: int | None = None, player_id: int | None = None, ) -> JumpRoll: """ Roll jump dice for stolen base attempt 1d20 check: - 1: Pickoff attempt (roll resolution d20) - 2: Balk check (roll resolution d20) - 3-20: Normal jump (roll 2d6 for jump rating) Args: league_id: 'sba' or 'pd' game_id: Optional UUID of game in progress team_id: Optional team ID for auditing player_id: Optional player/card ID for auditing (polymorphic) Returns: JumpRoll with conditional dice based on check_roll """ check_roll = self._roll_d20() jump_dice_a = None jump_dice_b = None resolution_roll = None if check_roll == 1 or check_roll == 2: # Pickoff or balk - roll resolution die resolution_roll = self._roll_d20() logger.debug( f"Jump check roll {check_roll}: {'pickoff' if check_roll == 1 else 'balk'}" ) else: # Normal jump - roll 2d6 jump_dice_a = self._roll_d6() jump_dice_b = self._roll_d6() logger.debug(f"Jump normal: {jump_dice_a} + {jump_dice_b}") roll = JumpRoll( roll_id=self._generate_roll_id(), roll_type=RollType.JUMP, league_id=league_id, timestamp=pendulum.now("UTC"), game_id=game_id, team_id=team_id, player_id=player_id, check_roll=check_roll, jump_dice_a=jump_dice_a, jump_dice_b=jump_dice_b, resolution_roll=resolution_roll, ) self._roll_history.append(roll) logger.info( f"Jump roll: {roll}", extra={ "roll_id": roll.roll_id, "game_id": str(game_id) if game_id else None, }, ) return roll def roll_fielding( self, position: str, league_id: str, game_id: UUID | None = None, team_id: int | None = None, player_id: int | None = None, ) -> FieldingRoll: """ Roll fielding check: 1d20 (range) + 3d6 (error) + 1d100 (rare play) Args: position: P, C, 1B, 2B, 3B, SS, LF, CF, RF league_id: 'sba' or 'pd' game_id: Optional UUID of game in progress team_id: Optional team ID for auditing player_id: Optional player/card ID for auditing (polymorphic) Returns: FieldingRoll with range, error, and rare play dice Raises: ValueError: If position is invalid """ VALID_POSITIONS = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"] if position not in VALID_POSITIONS: raise ValueError( f"Invalid position: {position}. Must be one of {VALID_POSITIONS}" ) d20 = self._roll_d20() d6_one = self._roll_d6() d6_two = self._roll_d6() d6_three = self._roll_d6() d100 = self._roll_d100() roll = FieldingRoll( roll_id=self._generate_roll_id(), roll_type=RollType.FIELDING, league_id=league_id, timestamp=pendulum.now("UTC"), game_id=game_id, team_id=team_id, player_id=player_id, position=position, d20=d20, d6_one=d6_one, d6_two=d6_two, d6_three=d6_three, d100=d100, error_total=0, # Calculated in __post_init__ _is_rare_play=False, ) self._roll_history.append(roll) logger.info( f"Fielding roll ({position}): {roll}", extra={ "roll_id": roll.roll_id, "position": position, "is_rare": roll.is_rare_play, "game_id": str(game_id) if game_id else None, }, ) return roll def roll_d20( self, league_id: str, game_id: UUID | None = None, team_id: int | None = None, player_id: int | None = None, ) -> D20Roll: """ Roll single d20 (modifiers applied to target, not roll) Args: league_id: 'sba' or 'pd' game_id: Optional UUID of game in progress team_id: Optional team ID for auditing player_id: Optional player/card ID for auditing (polymorphic) Returns: D20Roll with single die result """ base_roll = self._roll_d20() roll = D20Roll( roll_id=self._generate_roll_id(), roll_type=RollType.D20, league_id=league_id, timestamp=pendulum.now("UTC"), game_id=game_id, team_id=team_id, player_id=player_id, roll=base_roll, ) self._roll_history.append(roll) logger.info( f"D20 roll: {roll}", extra={ "roll_id": roll.roll_id, "game_id": str(game_id) if game_id else None, }, ) return roll def get_roll_history( self, roll_type: RollType | None = None, game_id: UUID | None = None, limit: int = 100, ) -> list[DiceRoll]: """ Get roll history with optional filtering Args: roll_type: Filter by specific roll type (AB, JUMP, FIELDING, D20) game_id: Filter by game UUID limit: Maximum number of rolls to return (most recent) Returns: List of DiceRoll objects matching filters """ filtered = self._roll_history if roll_type: filtered = [r for r in filtered if r.roll_type == roll_type] if game_id: filtered = [r for r in filtered if r.game_id == game_id] return filtered[-limit:] def get_rolls_since( self, game_id: UUID, since_timestamp: pendulum.DateTime ) -> list[DiceRoll]: """ Get all rolls for a game since a specific timestamp Used for batch persistence at end of innings. Args: game_id: UUID of game since_timestamp: Get rolls after this time Returns: List of DiceRoll objects for game since timestamp """ return [ roll for roll in self._roll_history if roll.game_id == game_id and roll.timestamp >= since_timestamp ] def verify_roll(self, roll_id: str) -> bool: """ Verify a roll ID exists in history Args: roll_id: Roll ID to verify Returns: True if roll exists in history """ return any(r.roll_id == roll_id for r in self._roll_history) def get_distribution_stats(self, roll_type: RollType | None = None) -> dict: """ Get distribution statistics for testing Args: roll_type: Optional filter by roll type Returns: Dictionary with roll counts by type """ rolls_to_analyze = self._roll_history if roll_type: rolls_to_analyze = [r for r in rolls_to_analyze if r.roll_type == roll_type] if not rolls_to_analyze: return {} stats = {"total_rolls": len(rolls_to_analyze), "by_type": {}} # Count by type for roll in rolls_to_analyze: roll_type_str = roll.roll_type.value if roll_type_str not in stats["by_type"]: stats["by_type"][roll_type_str] = 0 stats["by_type"][roll_type_str] += 1 return stats def clear_history(self) -> None: """Clear roll history (for testing)""" self._roll_history.clear() logger.debug("Roll history cleared") def get_stats(self) -> dict: """Get dice system statistics""" return { "total_rolls": len(self._roll_history), "by_type": self.get_distribution_stats()["by_type"] if self._roll_history else {}, } # Singleton instance dice_system = DiceSystem()