Backend: - Add home_team_dice_color and away_team_dice_color to GameState model - Extract dice_color from game metadata in StateManager (default: cc0000) - Add runners_on_base param to roll_ab for chaos check skipping Frontend - Dice Display: - Create DiceShapes.vue with SVG d6 (square) and d20 (hexagon) shapes - Apply home team's dice_color to d6 dice, white for resolution d20 - Show chaos d20 in amber only when WP/PB check triggered - Add automatic text contrast based on color luminance - Reduce blank space and remove info bubble from dice results Frontend - Player Cards: - Consolidate pitcher/batter cards to single location below diamond - Add active card highlighting based on dice roll (d6_one: 1-3=batter, 4-6=pitcher) - New card header format: [Team] Position [Name] with full card image - Remove redundant card displays from GameBoard and GameplayPanel - Enlarge PlayerCardModal on desktop (max-w-3xl at 1024px+) Tests: - Add DiceShapes.spec.ts with 34 tests for color calculations and rendering - Update DiceRoller.spec.ts for new DiceShapes integration - Fix test_roll_dice_success for new runners_on_base parameter Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
403 lines
12 KiB
Python
403 lines
12 KiB
Python
"""
|
|
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()
|