Spaces:
Running
Running
File size: 9,114 Bytes
741f475 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | """
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)]
|