""" Devil's Dozen - Knucklebones Game Engine A 2-player strategic dice placement game with grid-based mechanics. Game Rules: - 2 players, each with a 3x3 grid - Roll single D6, must place in any non-full column - "The Crunch": Placing a die destroys matching opponent dice in the same column - Column scoring: Singles (face value), Pairs (sum×2), Triples (sum×3) - Game ends when any grid is full; highest score wins """ import random from collections import Counter from dataclasses import dataclass from typing import ClassVar @dataclass(frozen=True) class GridState: """ Immutable representation of a player's 3×3 grid. Attributes: columns: Tuple of 3 columns, each containing 0-3 dice values (bottom-to-top) Example: ((4, 6), (1,), (4, 4, 2)) represents: Column 0: [4, 6] (bottom to top) Column 1: [1] Column 2: [4, 4, 2] """ columns: tuple[tuple[int, ...], tuple[int, ...], tuple[int, ...]] def __post_init__(self) -> None: """Validate grid structure.""" if len(self.columns) != 3: raise ValueError("Grid must have exactly 3 columns") for i, col in enumerate(self.columns): if len(col) > 3: raise ValueError(f"Column {i} has {len(col)} dice (max 3)") for die_value in col: if not (1 <= die_value <= 6): raise ValueError(f"Invalid die value {die_value} in column {i}") @classmethod def empty(cls) -> "GridState": """Create an empty grid.""" return cls(columns=((), (), ())) @classmethod def from_dict(cls, data: dict) -> "GridState": """Create GridState from database dictionary format.""" cols = data.get("columns", [[], [], []]) return cls(columns=(tuple(cols[0]), tuple(cols[1]), tuple(cols[2]))) def to_dict(self) -> dict: """Convert to database dictionary format.""" return {"columns": [list(col) for col in self.columns]} def is_full(self) -> bool: """Check if all columns are full (3 dice each).""" return all(len(col) == 3 for col in self.columns) def is_column_full(self, column_index: int) -> bool: """Check if a specific column is full.""" if not (0 <= column_index < 3): raise ValueError(f"Column index must be 0-2, got {column_index}") return len(self.columns[column_index]) == 3 @dataclass(frozen=True) class PlacementResult: """ Result of placing a die in a grid. Attributes: player_grid: Updated player grid after placement opponent_grid: Updated opponent grid after destruction player_score_delta: Change in player's score opponent_score_delta: Change in opponent's score (negative due to destruction) destroyed_count: Number of opponent dice destroyed column_index: Column where die was placed """ player_grid: GridState opponent_grid: GridState player_score_delta: int opponent_score_delta: int destroyed_count: int column_index: int class KnuckleboneEngine: """Stateless engine for Knucklebones game logic.""" GRID_COLUMNS: ClassVar[int] = 3 GRID_ROWS: ClassVar[int] = 3 DIE_FACES: ClassVar[int] = 6 @classmethod def roll_die(cls) -> int: """Roll a single D6.""" return random.randint(1, cls.DIE_FACES) @classmethod def calculate_column_score(cls, column: tuple[int, ...]) -> int: """ Calculate score for a single column. Scoring rules: - Single die: face value (e.g., [4] = 4 pts) - Pair (2 of kind): sum × 2 (e.g., [4, 4] = 16 pts) - Triple (3 of kind): sum × 3 (e.g., [4, 4, 4] = 36 pts) - Mixed values: sum with no multiplier (e.g., [4, 6] = 10 pts) Args: column: Tuple of 0-3 dice values Returns: Total points for the column """ if not column: return 0 # Count occurrences of each face value counts = Counter(column) total_score = 0 for face_value, count in counts.items(): if count == 3: # Triple: sum × 3 total_score += (face_value * 3) * 3 elif count == 2: # Pair: sum × 2 total_score += (face_value * 2) * 2 else: # Single: face value total_score += face_value return total_score @classmethod def calculate_grid_score(cls, grid: GridState) -> int: """ Calculate total score for a grid (sum of all column scores). Args: grid: The grid to score Returns: Total grid score """ return sum(cls.calculate_column_score(col) for col in grid.columns) @classmethod def place_die( cls, die_value: int, column_index: int, player_grid: GridState, opponent_grid: GridState ) -> PlacementResult: """ Place a die in a column and apply "The Crunch" mechanic. Steps: 1. Add die to player's column 2. Destroy matching opponent dice in the same column 3. Calculate score changes Args: die_value: Value of the die to place (1-6) column_index: Column to place in (0-2) player_grid: Current player's grid opponent_grid: Current opponent's grid Returns: PlacementResult with updated grids and score changes Raises: ValueError: If column is full or die_value is invalid """ # Validate inputs if not (1 <= die_value <= cls.DIE_FACES): raise ValueError(f"Die value must be 1-{cls.DIE_FACES}, got {die_value}") if not (0 <= column_index < cls.GRID_COLUMNS): raise ValueError(f"Column index must be 0-{cls.GRID_COLUMNS-1}, got {column_index}") if player_grid.is_column_full(column_index): raise ValueError(f"Column {column_index} is full") # Calculate score before changes player_score_before = cls.calculate_column_score(player_grid.columns[column_index]) opponent_score_before = cls.calculate_column_score(opponent_grid.columns[column_index]) # Add die to player's column new_player_column = player_grid.columns[column_index] + (die_value,) new_player_columns = list(player_grid.columns) new_player_columns[column_index] = new_player_column new_player_grid = GridState(columns=tuple(new_player_columns)) # Apply "The Crunch": Destroy matching opponent dice opponent_column = opponent_grid.columns[column_index] destroyed_count = opponent_column.count(die_value) new_opponent_column = tuple(v for v in opponent_column if v != die_value) new_opponent_columns = list(opponent_grid.columns) new_opponent_columns[column_index] = new_opponent_column new_opponent_grid = GridState(columns=tuple(new_opponent_columns)) # Calculate score after changes player_score_after = cls.calculate_column_score(new_player_column) opponent_score_after = cls.calculate_column_score(new_opponent_column) return PlacementResult( player_grid=new_player_grid, opponent_grid=new_opponent_grid, player_score_delta=player_score_after - player_score_before, opponent_score_delta=opponent_score_after - opponent_score_before, destroyed_count=destroyed_count, column_index=column_index ) @classmethod def is_game_over(cls, player1_grid: GridState, player2_grid: GridState) -> bool: """ Check if the game is over (any grid is full). Args: player1_grid: Player 1's grid player2_grid: Player 2's grid Returns: True if either grid is completely full """ return player1_grid.is_full() or player2_grid.is_full() @classmethod def get_winner( cls, player1_grid: GridState, player2_grid: GridState ) -> int | None: """ Determine the winner based on final scores. Args: player1_grid: Player 1's grid player2_grid: Player 2's grid Returns: 1 if player 1 wins, 2 if player 2 wins, None for tie """ p1_score = cls.calculate_grid_score(player1_grid) p2_score = cls.calculate_grid_score(player2_grid) if p1_score > p2_score: return 1 elif p2_score > p1_score: return 2 else: return None # Tie @classmethod def get_available_columns(cls, grid: GridState) -> list[int]: """ Get list of column indices that are not full. Args: grid: The grid to check Returns: List of available column indices (0-2) """ return [i for i in range(cls.GRID_COLUMNS) if not grid.is_column_full(i)]