strat-gameplay-webapp/backend/app/core/dice.py
Cal Corum 2b8fea36a8 CLAUDE: Redesign dice display with team colors and consolidate player cards
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>
2026-01-24 00:16:32 -06:00

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