Spaces:
Running
Running
| """ | |
| Devil's Dozen - Game Engine Base Classes | |
| This module defines the foundational data structures and enums used throughout | |
| the game engine. All classes are immutable (frozen dataclasses) to ensure | |
| thread-safety and predictable behavior. | |
| """ | |
| from dataclasses import dataclass, field | |
| from enum import Enum, auto | |
| from typing import Sequence | |
| class DiceType(Enum): | |
| """Type of dice used in the game.""" | |
| D6 = 6 | |
| D20 = 20 | |
| class GameMode(Enum): | |
| """Available game modes.""" | |
| PEASANTS_GAMBLE = "peasants_gamble" | |
| ALCHEMISTS_ASCENT = "alchemists_ascent" | |
| KNUCKLEBONES = "knucklebones" | |
| ALIEN_INVASION = "alien_invasion" | |
| PIG = "pig" | |
| class Tier(Enum): | |
| """Tiers for Alchemist's Ascent mode.""" | |
| RED = 1 # 0-100 points, 8 dice | |
| GREEN = 2 # 101-200 points, 3 dice | |
| BLUE = 3 # 201-250 points, 1 die | |
| class ScoringCategory(Enum): | |
| """Categories of scoring combinations.""" | |
| SINGLE_ONE = auto() | |
| SINGLE_FIVE = auto() | |
| THREE_OF_A_KIND = auto() | |
| FOUR_OF_A_KIND = auto() | |
| FIVE_OF_A_KIND = auto() | |
| SIX_OF_A_KIND = auto() | |
| LOW_STRAIGHT = auto() # 1-2-3-4-5 | |
| HIGH_STRAIGHT = auto() # 2-3-4-5-6 | |
| FULL_STRAIGHT = auto() # 1-2-3-4-5-6 | |
| PAIR = auto() # D20 mode | |
| SEQUENCE = auto() # D20 mode | |
| TIER_BONUS = auto() # D20 mode multipliers | |
| class ScoringBreakdown: | |
| """ | |
| A single scoring component within a roll. | |
| Attributes: | |
| category: The type of scoring combination | |
| dice_values: The dice that contributed to this score | |
| points: Points awarded for this combination | |
| description: Human-readable description | |
| """ | |
| category: ScoringCategory | |
| dice_values: tuple[int, ...] | |
| points: int | |
| description: str | |
| class ScoringResult: | |
| """ | |
| Complete scoring result for a dice roll. | |
| Attributes: | |
| points: Total points scored | |
| breakdown: List of individual scoring components | |
| scoring_dice_indices: Indices of dice that scored | |
| is_bust: Whether no dice scored (automatic bust) | |
| """ | |
| points: int | |
| breakdown: tuple[ScoringBreakdown, ...] | |
| scoring_dice_indices: frozenset[int] | |
| is_bust: bool = False | |
| def has_scoring_dice(self) -> bool: | |
| """Returns True if at least one die scored.""" | |
| return len(self.scoring_dice_indices) > 0 | |
| def __str__(self) -> str: | |
| if self.is_bust: | |
| return "BUST! No scoring dice." | |
| lines = [f"Total: {self.points} points"] | |
| for item in self.breakdown: | |
| lines.append(f" - {item.description}: {item.points}") | |
| return "\n".join(lines) | |
| class DiceRoll: | |
| """ | |
| Immutable representation of a dice roll. | |
| Attributes: | |
| values: Tuple of dice face values | |
| dice_type: Type of dice (D6 or D20) | |
| """ | |
| values: tuple[int, ...] | |
| dice_type: DiceType = DiceType.D6 | |
| def __post_init__(self) -> None: | |
| """Validate dice values are within valid range.""" | |
| max_value = self.dice_type.value | |
| for value in self.values: | |
| if not (1 <= value <= max_value): | |
| raise ValueError( | |
| f"Invalid die value {value} for {self.dice_type.name}. " | |
| f"Must be between 1 and {max_value}." | |
| ) | |
| def __len__(self) -> int: | |
| return len(self.values) | |
| def __getitem__(self, index: int) -> int: | |
| return self.values[index] | |
| def from_sequence( | |
| cls, | |
| values: Sequence[int], | |
| dice_type: DiceType = DiceType.D6 | |
| ) -> "DiceRoll": | |
| """Create a DiceRoll from any sequence type.""" | |
| return cls(values=tuple(values), dice_type=dice_type) | |
| class TurnState: | |
| """ | |
| Complete state of a player's turn. | |
| Attributes: | |
| active_dice: Current dice values in play | |
| held_indices: Indices of dice that have been held | |
| turn_score: Points accumulated this turn (not yet banked) | |
| roll_count: Number of rolls taken this turn | |
| is_hot_dice: Whether all dice have scored (can roll all again) | |
| tier: Current tier (Alchemist's Ascent only) | |
| previous_dice: Previous roll for reroll comparison (Tier 2) | |
| """ | |
| active_dice: tuple[int, ...] | |
| held_indices: frozenset[int] = field(default_factory=frozenset) | |
| turn_score: int = 0 | |
| roll_count: int = 0 | |
| is_hot_dice: bool = False | |
| tier: Tier = Tier.RED | |
| previous_dice: tuple[int, ...] = field(default_factory=tuple) | |
| def available_dice_count(self) -> int: | |
| """Number of dice that can still be rolled.""" | |
| return len(self.active_dice) - len(self.held_indices) | |
| def held_dice_values(self) -> tuple[int, ...]: | |
| """Values of the held dice.""" | |
| return tuple(self.active_dice[i] for i in sorted(self.held_indices)) | |
| def unheld_dice_values(self) -> tuple[int, ...]: | |
| """Values of dice not yet held.""" | |
| return tuple( | |
| v for i, v in enumerate(self.active_dice) | |
| if i not in self.held_indices | |
| ) | |
| class GameConfig: | |
| """ | |
| Configuration for a game session. | |
| Attributes: | |
| mode: Game mode (Peasant's Gamble or Alchemist's Ascent) | |
| target_score: Score needed to win | |
| num_players: Number of players (2-4) | |
| """ | |
| mode: GameMode | |
| target_score: int | |
| num_players: int = 2 | |
| def __post_init__(self) -> None: | |
| """Validate configuration.""" | |
| if self.mode == GameMode.KNUCKLEBONES: | |
| # Knucklebones is strictly 2-player | |
| if self.num_players != 2: | |
| raise ValueError("Knucklebones requires exactly 2 players.") | |
| elif self.mode == GameMode.PIG: | |
| # Pig supports 2-10 players | |
| if not 2 <= self.num_players <= 10: | |
| raise ValueError("Number of players for Pig must be between 2 and 10.") | |
| else: | |
| # Other modes support 2-4 players | |
| if not 2 <= self.num_players <= 4: | |
| raise ValueError("Number of players must be between 2 and 4.") | |
| if self.mode == GameMode.PEASANTS_GAMBLE: | |
| valid_targets = {3000, 5000, 10000} | |
| if self.target_score not in valid_targets: | |
| raise ValueError( | |
| f"Target score for Peasant's Gamble must be one of {valid_targets}." | |
| ) | |
| elif self.mode == GameMode.ALCHEMISTS_ASCENT: | |
| if self.target_score != 250: | |
| raise ValueError("Target score for Alchemist's Ascent must be 250.") | |
| elif self.mode == GameMode.KNUCKLEBONES: | |
| # Target score is not used in Knucklebones (game ends on full grid) | |
| pass | |
| elif self.mode == GameMode.ALIEN_INVASION: | |
| valid_targets = {25, 50, 75} | |
| if self.target_score not in valid_targets: | |
| raise ValueError( | |
| f"Target score for Alien Invasion must be one of {valid_targets}." | |
| ) | |
| elif self.mode == GameMode.PIG: | |
| valid_targets = {50, 100, 250} | |
| if self.target_score not in valid_targets: | |
| raise ValueError( | |
| f"Target score for Pig must be one of {valid_targets}." | |
| ) | |