chuk-puzzles-gym 0.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. chuk_puzzles_gym/__init__.py +19 -0
  2. chuk_puzzles_gym/constants.py +9 -0
  3. chuk_puzzles_gym/eval.py +763 -0
  4. chuk_puzzles_gym/export/__init__.py +20 -0
  5. chuk_puzzles_gym/export/dataset.py +376 -0
  6. chuk_puzzles_gym/games/__init__.py +94 -0
  7. chuk_puzzles_gym/games/_base/__init__.py +6 -0
  8. chuk_puzzles_gym/games/_base/commands.py +91 -0
  9. chuk_puzzles_gym/games/_base/game.py +337 -0
  10. chuk_puzzles_gym/games/binary/__init__.py +6 -0
  11. chuk_puzzles_gym/games/binary/config.py +23 -0
  12. chuk_puzzles_gym/games/binary/game.py +434 -0
  13. chuk_puzzles_gym/games/bridges/__init__.py +6 -0
  14. chuk_puzzles_gym/games/bridges/config.py +24 -0
  15. chuk_puzzles_gym/games/bridges/game.py +489 -0
  16. chuk_puzzles_gym/games/einstein/__init__.py +6 -0
  17. chuk_puzzles_gym/games/einstein/config.py +23 -0
  18. chuk_puzzles_gym/games/einstein/constants.py +13 -0
  19. chuk_puzzles_gym/games/einstein/game.py +366 -0
  20. chuk_puzzles_gym/games/einstein/models.py +35 -0
  21. chuk_puzzles_gym/games/fillomino/__init__.py +6 -0
  22. chuk_puzzles_gym/games/fillomino/config.py +24 -0
  23. chuk_puzzles_gym/games/fillomino/game.py +516 -0
  24. chuk_puzzles_gym/games/futoshiki/__init__.py +6 -0
  25. chuk_puzzles_gym/games/futoshiki/config.py +23 -0
  26. chuk_puzzles_gym/games/futoshiki/game.py +391 -0
  27. chuk_puzzles_gym/games/hidato/__init__.py +6 -0
  28. chuk_puzzles_gym/games/hidato/config.py +24 -0
  29. chuk_puzzles_gym/games/hidato/game.py +403 -0
  30. chuk_puzzles_gym/games/hitori/__init__.py +6 -0
  31. chuk_puzzles_gym/games/hitori/config.py +23 -0
  32. chuk_puzzles_gym/games/hitori/game.py +451 -0
  33. chuk_puzzles_gym/games/kakuro/__init__.py +6 -0
  34. chuk_puzzles_gym/games/kakuro/config.py +24 -0
  35. chuk_puzzles_gym/games/kakuro/game.py +399 -0
  36. chuk_puzzles_gym/games/kenken/__init__.py +6 -0
  37. chuk_puzzles_gym/games/kenken/config.py +24 -0
  38. chuk_puzzles_gym/games/kenken/enums.py +13 -0
  39. chuk_puzzles_gym/games/kenken/game.py +486 -0
  40. chuk_puzzles_gym/games/kenken/models.py +15 -0
  41. chuk_puzzles_gym/games/killer_sudoku/__init__.py +6 -0
  42. chuk_puzzles_gym/games/killer_sudoku/config.py +23 -0
  43. chuk_puzzles_gym/games/killer_sudoku/game.py +502 -0
  44. chuk_puzzles_gym/games/killer_sudoku/models.py +15 -0
  45. chuk_puzzles_gym/games/knapsack/__init__.py +6 -0
  46. chuk_puzzles_gym/games/knapsack/config.py +24 -0
  47. chuk_puzzles_gym/games/knapsack/enums.py +10 -0
  48. chuk_puzzles_gym/games/knapsack/game.py +340 -0
  49. chuk_puzzles_gym/games/knapsack/models.py +13 -0
  50. chuk_puzzles_gym/games/lights_out/__init__.py +6 -0
  51. chuk_puzzles_gym/games/lights_out/config.py +24 -0
  52. chuk_puzzles_gym/games/lights_out/game.py +249 -0
  53. chuk_puzzles_gym/games/logic_grid/__init__.py +6 -0
  54. chuk_puzzles_gym/games/logic_grid/config.py +24 -0
  55. chuk_puzzles_gym/games/logic_grid/constants.py +12 -0
  56. chuk_puzzles_gym/games/logic_grid/game.py +333 -0
  57. chuk_puzzles_gym/games/logic_grid/models.py +24 -0
  58. chuk_puzzles_gym/games/mastermind/__init__.py +6 -0
  59. chuk_puzzles_gym/games/mastermind/config.py +25 -0
  60. chuk_puzzles_gym/games/mastermind/game.py +297 -0
  61. chuk_puzzles_gym/games/minesweeper/__init__.py +6 -0
  62. chuk_puzzles_gym/games/minesweeper/config.py +24 -0
  63. chuk_puzzles_gym/games/minesweeper/enums.py +12 -0
  64. chuk_puzzles_gym/games/minesweeper/game.py +432 -0
  65. chuk_puzzles_gym/games/nonogram/__init__.py +6 -0
  66. chuk_puzzles_gym/games/nonogram/config.py +23 -0
  67. chuk_puzzles_gym/games/nonogram/game.py +296 -0
  68. chuk_puzzles_gym/games/nurikabe/__init__.py +6 -0
  69. chuk_puzzles_gym/games/nurikabe/config.py +24 -0
  70. chuk_puzzles_gym/games/nurikabe/enums.py +14 -0
  71. chuk_puzzles_gym/games/nurikabe/game.py +586 -0
  72. chuk_puzzles_gym/games/scheduler/__init__.py +6 -0
  73. chuk_puzzles_gym/games/scheduler/config.py +25 -0
  74. chuk_puzzles_gym/games/scheduler/constants.py +15 -0
  75. chuk_puzzles_gym/games/scheduler/enums.py +10 -0
  76. chuk_puzzles_gym/games/scheduler/game.py +431 -0
  77. chuk_puzzles_gym/games/scheduler/models.py +14 -0
  78. chuk_puzzles_gym/games/shikaku/__init__.py +6 -0
  79. chuk_puzzles_gym/games/shikaku/config.py +24 -0
  80. chuk_puzzles_gym/games/shikaku/game.py +419 -0
  81. chuk_puzzles_gym/games/slitherlink/__init__.py +6 -0
  82. chuk_puzzles_gym/games/slitherlink/config.py +23 -0
  83. chuk_puzzles_gym/games/slitherlink/game.py +386 -0
  84. chuk_puzzles_gym/games/sokoban/__init__.py +6 -0
  85. chuk_puzzles_gym/games/sokoban/config.py +24 -0
  86. chuk_puzzles_gym/games/sokoban/game.py +671 -0
  87. chuk_puzzles_gym/games/star_battle/__init__.py +6 -0
  88. chuk_puzzles_gym/games/star_battle/config.py +24 -0
  89. chuk_puzzles_gym/games/star_battle/game.py +390 -0
  90. chuk_puzzles_gym/games/sudoku/__init__.py +7 -0
  91. chuk_puzzles_gym/games/sudoku/commands.py +96 -0
  92. chuk_puzzles_gym/games/sudoku/config.py +22 -0
  93. chuk_puzzles_gym/games/sudoku/game.py +328 -0
  94. chuk_puzzles_gym/games/tents/__init__.py +6 -0
  95. chuk_puzzles_gym/games/tents/config.py +24 -0
  96. chuk_puzzles_gym/games/tents/game.py +416 -0
  97. chuk_puzzles_gym/gym_env.py +465 -0
  98. chuk_puzzles_gym/models/__init__.py +47 -0
  99. chuk_puzzles_gym/models/base.py +30 -0
  100. chuk_puzzles_gym/models/config.py +11 -0
  101. chuk_puzzles_gym/models/enums.py +104 -0
  102. chuk_puzzles_gym/models/evaluation.py +487 -0
  103. chuk_puzzles_gym/models/games.py +12 -0
  104. chuk_puzzles_gym/server.py +1171 -0
  105. chuk_puzzles_gym/trace/__init__.py +10 -0
  106. chuk_puzzles_gym/trace/generator.py +726 -0
  107. chuk_puzzles_gym/utils/__init__.py +4 -0
  108. chuk_puzzles_gym-0.9.dist-info/METADATA +1471 -0
  109. chuk_puzzles_gym-0.9.dist-info/RECORD +112 -0
  110. chuk_puzzles_gym-0.9.dist-info/WHEEL +5 -0
  111. chuk_puzzles_gym-0.9.dist-info/entry_points.txt +4 -0
  112. chuk_puzzles_gym-0.9.dist-info/top_level.txt +1 -0
@@ -0,0 +1,486 @@
1
+ """KenKen puzzle game implementation."""
2
+
3
+ from typing import Any
4
+
5
+ from ...models import DifficultyProfile, MoveResult
6
+ from .._base import PuzzleGame
7
+ from .config import KenKenConfig
8
+ from .enums import ArithmeticOperation
9
+ from .models import Cage
10
+
11
+
12
+ class KenKenGame(PuzzleGame):
13
+ """KenKen (also known as Calcudoku or Mathdoku) puzzle game.
14
+
15
+ Similar to Sudoku but uses arithmetic cages with operations.
16
+ Each cage has a target number and an operation (+, -, *, /).
17
+ """
18
+
19
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
20
+ """Initialize a new KenKen game.
21
+
22
+ Args:
23
+ difficulty: Game difficulty level (easy=4x4, medium=5x5, hard=6x6)
24
+ """
25
+ super().__init__(difficulty, seed, **kwargs)
26
+
27
+ # Grid size based on difficulty
28
+ self.config = KenKenConfig.from_difficulty(self.difficulty)
29
+ self.size = self.config.size
30
+
31
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
32
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
33
+ self.initial_grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
34
+
35
+ # Cages: list of Cage objects
36
+ self.cages: list[Cage] = []
37
+
38
+ @property
39
+ def name(self) -> str:
40
+ """The display name of this puzzle type."""
41
+ return "KenKen"
42
+
43
+ @property
44
+ def description(self) -> str:
45
+ """A one-line description of this puzzle type."""
46
+ return "Arithmetic cage puzzle - combine math and logic"
47
+
48
+ @property
49
+ def constraint_types(self) -> list[str]:
50
+ """Constraint types demonstrated by this puzzle."""
51
+ return ["all_different", "arithmetic_cages", "operations", "multi_operation_constraints"]
52
+
53
+ @property
54
+ def business_analogies(self) -> list[str]:
55
+ """Business problems this puzzle models."""
56
+ return ["resource_groups", "operational_constraints", "mathematical_relationships", "grouped_calculations"]
57
+
58
+ @property
59
+ def complexity_profile(self) -> dict[str, str]:
60
+ """Complexity profile of this puzzle."""
61
+ return {"reasoning_type": "deductive", "search_space": "medium", "constraint_density": "moderate"}
62
+
63
+ @property
64
+ def optimal_steps(self) -> int | None:
65
+ """Minimum steps = empty cells to fill."""
66
+ return sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
67
+
68
+ @property
69
+ def difficulty_profile(self) -> "DifficultyProfile":
70
+ """Difficulty characteristics for KenKen."""
71
+ from ...models import DifficultyLevel
72
+
73
+ empty = self.optimal_steps or 0
74
+ total = self.size * self.size
75
+ logic_depth = {
76
+ DifficultyLevel.EASY.value: 2,
77
+ DifficultyLevel.MEDIUM.value: 4,
78
+ DifficultyLevel.HARD.value: 5,
79
+ }.get(self.difficulty.value, 3)
80
+ branching = 2.0 + (empty / total) * 3
81
+ density = 1.0 - (empty / total) if total > 0 else 0.5
82
+ return DifficultyProfile(
83
+ logic_depth=logic_depth,
84
+ branching_factor=round(branching, 1),
85
+ state_observability=1.0,
86
+ constraint_density=round(density, 2),
87
+ )
88
+
89
+ def is_valid_move(self, row: int, col: int, num: int, grid: list[list[int]] | None = None) -> bool:
90
+ """Check if placing num at (row, col) is valid.
91
+
92
+ Args:
93
+ row: Row index (0-indexed)
94
+ col: Column index (0-indexed)
95
+ num: Number to place (1 to self.size)
96
+ grid: Grid to check against (defaults to self.grid)
97
+
98
+ Returns:
99
+ True if the move is valid, False otherwise
100
+ """
101
+ if grid is None:
102
+ grid = self.grid
103
+
104
+ # Check row uniqueness
105
+ for c in range(self.size):
106
+ if c != col and grid[row][c] == num:
107
+ return False
108
+
109
+ # Check column uniqueness
110
+ for r in range(self.size):
111
+ if r != row and grid[r][col] == num:
112
+ return False
113
+
114
+ return True
115
+
116
+ def solve(self, grid: list[list[int]]) -> bool:
117
+ """Solve the KenKen puzzle using backtracking.
118
+
119
+ Args:
120
+ grid: The KenKen grid to solve
121
+
122
+ Returns:
123
+ True if solved, False otherwise
124
+ """
125
+ for row in range(self.size):
126
+ for col in range(self.size):
127
+ if grid[row][col] == 0:
128
+ for num in range(1, self.size + 1):
129
+ grid[row][col] = num
130
+
131
+ if self.is_valid_move(row, col, num, grid) and self._check_cage_constraints(grid, row, col):
132
+ if self.solve(grid):
133
+ return True
134
+
135
+ grid[row][col] = 0
136
+
137
+ return False
138
+ return True
139
+
140
+ def _check_cage_constraints(self, grid: list[list[int]], row: int, col: int) -> bool:
141
+ """Check if the cage containing (row, col) is still valid.
142
+
143
+ Args:
144
+ grid: Current grid state
145
+ row: Row of the cell that was just filled
146
+ col: Column of the cell that was just filled
147
+
148
+ Returns:
149
+ True if cage constraints are satisfied or could be satisfied
150
+ """
151
+ # Find which cage contains this cell
152
+ for cage in self.cages:
153
+ if (row, col) not in cage.cells:
154
+ continue
155
+
156
+ # Get all values in the cage
157
+ cage_values = [grid[r][c] for r, c in cage.cells]
158
+
159
+ # If cage is not fully filled, we can only do partial checking
160
+ if 0 in cage_values:
161
+ # For now, allow partial fills (optimistic checking)
162
+ # More sophisticated pruning could be added here
163
+ continue
164
+
165
+ # All cells filled - check if operation gives target
166
+ if not self._evaluate_cage(cage_values, cage.operation, cage.target):
167
+ return False
168
+
169
+ return True
170
+
171
+ def _evaluate_cage(self, values: list[int], operation: ArithmeticOperation | None, target: int) -> bool:
172
+ """Check if the cage operation evaluates to the target.
173
+
174
+ Args:
175
+ values: List of values in the cage
176
+ operation: Operation to perform
177
+ target: Target value
178
+
179
+ Returns:
180
+ True if operation on values equals target
181
+ """
182
+ if operation is None or operation == ArithmeticOperation.NONE:
183
+ # Single cell cage
184
+ return len(values) == 1 and values[0] == target
185
+
186
+ if operation == ArithmeticOperation.ADD:
187
+ return sum(values) == target
188
+
189
+ if operation == ArithmeticOperation.MULTIPLY:
190
+ result = 1
191
+ for v in values:
192
+ result *= v
193
+ return result == target
194
+
195
+ if operation == ArithmeticOperation.SUBTRACT:
196
+ # Subtraction: target = larger - smaller (for 2 cells)
197
+ if len(values) != 2:
198
+ return False
199
+ return abs(values[0] - values[1]) == target
200
+
201
+ if operation == ArithmeticOperation.DIVIDE:
202
+ # Division: target = larger / smaller (for 2 cells)
203
+ if len(values) != 2:
204
+ return False
205
+ a, b = sorted(values, reverse=True)
206
+ return b != 0 and a % b == 0 and a // b == target
207
+
208
+ return False
209
+
210
+ def _generate_cages(self) -> None:
211
+ """Generate cages for the puzzle."""
212
+ # Simple cage generation: create random connected regions
213
+ used = [[False for _ in range(self.size)] for _ in range(self.size)]
214
+ self.cages = []
215
+
216
+ for row in range(self.size):
217
+ for col in range(self.size):
218
+ if used[row][col]:
219
+ continue
220
+
221
+ # Start a new cage
222
+ cage_size = self._rng.randint(1, 3) # 1-3 cells per cage
223
+ cells = [(row, col)]
224
+ used[row][col] = True
225
+
226
+ # Try to add more cells
227
+ for _ in range(cage_size - 1):
228
+ # Find adjacent unused cells
229
+ candidates = []
230
+ for r, c in cells:
231
+ for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
232
+ nr, nc = r + dr, c + dc
233
+ if 0 <= nr < self.size and 0 <= nc < self.size and not used[nr][nc]:
234
+ candidates.append((nr, nc))
235
+
236
+ if candidates:
237
+ nr, nc = self._rng.choice(candidates)
238
+ cells.append((nr, nc))
239
+ used[nr][nc] = True
240
+
241
+ # Determine operation and target from solution
242
+ cage_values = [self.solution[r][c] for r, c in cells]
243
+
244
+ if len(cells) == 1:
245
+ operation = None
246
+ target = cage_values[0]
247
+ else:
248
+ # Choose operation based on cage size
249
+ if len(cells) == 2:
250
+ operations = [
251
+ ArithmeticOperation.ADD,
252
+ ArithmeticOperation.SUBTRACT,
253
+ ArithmeticOperation.MULTIPLY,
254
+ ArithmeticOperation.DIVIDE,
255
+ ]
256
+ else:
257
+ operations = [ArithmeticOperation.ADD, ArithmeticOperation.MULTIPLY]
258
+
259
+ operation = self._rng.choice(operations)
260
+
261
+ if operation == ArithmeticOperation.ADD:
262
+ target = sum(cage_values)
263
+ elif operation == ArithmeticOperation.MULTIPLY:
264
+ target = 1
265
+ for v in cage_values:
266
+ target *= v
267
+ elif operation == ArithmeticOperation.SUBTRACT:
268
+ target = abs(cage_values[0] - cage_values[1])
269
+ elif operation == ArithmeticOperation.DIVIDE:
270
+ a, b = sorted(cage_values, reverse=True)
271
+ if b == 0 or a % b != 0:
272
+ # Fallback to addition if division doesn't work
273
+ operation = ArithmeticOperation.ADD
274
+ target = sum(cage_values)
275
+ else:
276
+ target = a // b
277
+
278
+ self.cages.append(Cage(cells=cells, operation=operation, target=target))
279
+
280
+ async def generate_puzzle(self) -> None:
281
+ """Generate a new KenKen puzzle."""
282
+ # Generate a valid Latin square as solution
283
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
284
+
285
+ # Simple solution generation: shifted rows
286
+ for row in range(self.size):
287
+ for col in range(self.size):
288
+ self.grid[row][col] = (row + col) % self.size + 1
289
+
290
+ # Shuffle rows and columns to make it more random
291
+ row_order = list(range(self.size))
292
+ col_order = list(range(self.size))
293
+ self._rng.shuffle(row_order)
294
+ self._rng.shuffle(col_order)
295
+
296
+ shuffled = [[self.grid[row_order[r]][col_order[c]] for c in range(self.size)] for r in range(self.size)]
297
+ self.solution = shuffled
298
+
299
+ # Generate cages
300
+ self._generate_cages()
301
+
302
+ # Empty the grid (KenKen starts completely empty)
303
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
304
+ self.initial_grid = [row[:] for row in self.grid]
305
+ self.moves_made = 0
306
+ self.game_started = True
307
+
308
+ async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
309
+ """Place a number on the grid.
310
+
311
+ Args:
312
+ row: Row index (1-indexed, user-facing)
313
+ col: Column index (1-indexed, user-facing)
314
+ num: Number to place (1 to self.size, or 0 to clear)
315
+
316
+ Returns:
317
+ MoveResult indicating success/failure and message
318
+ """
319
+ # Convert to 0-indexed
320
+ row -= 1
321
+ col -= 1
322
+
323
+ # Validate coordinates
324
+ if not (0 <= row < self.size and 0 <= col < self.size):
325
+ return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
326
+
327
+ # Clear the cell
328
+ if num == 0:
329
+ self.grid[row][col] = 0
330
+ return MoveResult(success=True, message="Cell cleared.", state_changed=True)
331
+
332
+ # Validate number
333
+ if not (1 <= num <= self.size):
334
+ return MoveResult(success=False, message=f"Invalid number. Use 1-{self.size} or 0 to clear.")
335
+
336
+ # Check if the move is valid
337
+ old_value = self.grid[row][col]
338
+ self.grid[row][col] = num
339
+
340
+ if not self.is_valid_move(row, col, num):
341
+ self.grid[row][col] = old_value
342
+ return MoveResult(success=False, message="Invalid move! This number already exists in the row or column.")
343
+
344
+ self.moves_made += 1
345
+ return MoveResult(success=True, message="Number placed successfully!", state_changed=True)
346
+
347
+ def is_complete(self) -> bool:
348
+ """Check if the puzzle is complete and correct."""
349
+ # Check all cells filled
350
+ for row in range(self.size):
351
+ for col in range(self.size):
352
+ if self.grid[row][col] == 0:
353
+ return False
354
+
355
+ # Check all cages
356
+ for cage in self.cages:
357
+ cage_values = [self.grid[r][c] for r, c in cage.cells]
358
+ if not self._evaluate_cage(cage_values, cage.operation, cage.target):
359
+ return False
360
+
361
+ return True
362
+
363
+ async def get_hint(self) -> tuple[Any, str] | None:
364
+ """Get a hint for the next move.
365
+
366
+ Returns:
367
+ Tuple of (hint_data, hint_message) or None if puzzle is complete
368
+ """
369
+ empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0]
370
+ if not empty_cells:
371
+ return None
372
+
373
+ row, col = self._rng.choice(empty_cells)
374
+ hint_data = (row + 1, col + 1, self.solution[row][col])
375
+ hint_message = f"Try placing {self.solution[row][col]} at row {row + 1}, column {col + 1}"
376
+ return hint_data, hint_message
377
+
378
+ def render_grid(self) -> str:
379
+ """Render the current puzzle state as ASCII art.
380
+
381
+ Returns:
382
+ String representation of the puzzle grid with cages
383
+ """
384
+ lines = []
385
+
386
+ # Create a cage ID map for rendering
387
+ cage_map = {}
388
+ for cage_id, cage in enumerate(self.cages):
389
+ for r, c in cage.cells:
390
+ cage_map[(r, c)] = (cage_id, cage.operation, cage.target)
391
+
392
+ # Determine cell width needed (accommodate cage labels)
393
+ max_label_len = 0
394
+ for cage in self.cages:
395
+ op_str = cage.operation.value if cage.operation else ""
396
+ label_len = len(f"{cage.target}{op_str}")
397
+ max_label_len = max(max_label_len, label_len)
398
+
399
+ # Cell width: 1 (space) + 1 (digit/.) + max_label_len, minimum 4
400
+ cell_width = max(4, 2 + max_label_len)
401
+
402
+ # Header - center column numbers within each cell width
403
+ # Row format is "N |..." so header should be " |..." to align pipes
404
+ header = " |" # 2 spaces + pipe to match row format "N |"
405
+ for i in range(self.size):
406
+ col_num = str(i + 1)
407
+ # Center the column number in the cell width
408
+ padding_left = (cell_width - len(col_num)) // 2
409
+ padding_right = cell_width - len(col_num) - padding_left
410
+ cell_header = " " * padding_left + col_num + " " * padding_right
411
+ header += cell_header + "|"
412
+ lines.append(header)
413
+
414
+ lines.append(" +" + ("-" * cell_width + "+") * self.size)
415
+
416
+ for row in range(self.size):
417
+ line = f"{row + 1} |"
418
+ for col in range(self.size):
419
+ cell = self.grid[row][col]
420
+
421
+ # Start with the cell value
422
+ if cell != 0:
423
+ cell_content = str(cell)
424
+ else:
425
+ cell_content = "."
426
+ # Show cage info in first cell of cage (only if cell is empty)
427
+ cage_id, operation, target = cage_map.get((row, col), (None, None, None))
428
+ if cage_id is not None:
429
+ # Check if this is the first cell of the cage
430
+ cage_cells = self.cages[cage_id].cells
431
+ if (row, col) == min(cage_cells):
432
+ op_str = operation.value if operation else ""
433
+ cage_label = f"{target}{op_str}"
434
+ cell_content = f"{cell_content}{cage_label}"
435
+
436
+ # Pad to fixed width (cell_width includes the border spacing)
437
+ padded_content = f" {cell_content}".ljust(cell_width)
438
+ line += f"{padded_content}|"
439
+ lines.append(line)
440
+ lines.append(" +" + ("-" * cell_width + "+") * self.size)
441
+
442
+ # Show cage legend
443
+ lines.append("\nCages:")
444
+ for _cage_id, cage in enumerate(self.cages):
445
+ op_str = cage.operation.value if cage.operation else ""
446
+ cells_str = ", ".join(f"({r + 1},{c + 1})" for r, c in sorted(cage.cells))
447
+ lines.append(f" {cage.target}{op_str}: {cells_str}")
448
+
449
+ return "\n".join(lines)
450
+
451
+ def get_rules(self) -> str:
452
+ """Get the rules description for KenKen.
453
+
454
+ Returns:
455
+ Multi-line string describing the puzzle rules
456
+ """
457
+ return f"""KENKEN RULES:
458
+ - Fill {self.size}x{self.size} grid with 1-{self.size}
459
+ - No repeats in rows or columns
460
+ - Satisfy cage arithmetic constraints
461
+ - Operations: + - * /"""
462
+
463
+ def get_commands(self) -> str:
464
+ """Get the available commands for KenKen.
465
+
466
+ Returns:
467
+ Multi-line string describing available commands
468
+ """
469
+ return """KENKEN COMMANDS:
470
+ place <row> <col> <num> - Place a number (e.g., 'place 1 2 4')
471
+ clear <row> <col> - Clear a cell
472
+ show - Display the current grid
473
+ hint - Get a hint for the next move
474
+ check - Check your progress
475
+ solve - Show the solution (ends game)
476
+ menu - Return to game selection
477
+ quit - Exit the server"""
478
+
479
+ def get_stats(self) -> str:
480
+ """Get current game statistics.
481
+
482
+ Returns:
483
+ String with game stats
484
+ """
485
+ empty = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
486
+ return f"Moves made: {self.moves_made} | Empty cells: {empty} | Grid size: {self.size}x{self.size} | Seed: {self.seed}"
@@ -0,0 +1,15 @@
1
+ """KenKen game models."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from .enums import ArithmeticOperation
6
+
7
+
8
+ class Cage(BaseModel):
9
+ """A cage in KenKen game."""
10
+
11
+ model_config = ConfigDict(frozen=True) # Cages don't change once created
12
+
13
+ cells: list[tuple[int, int]] = Field(min_length=1, description="List of cell coordinates (0-indexed)")
14
+ operation: ArithmeticOperation | None = Field(description="Arithmetic operation for the cage")
15
+ target: int = Field(description="Target value for the cage")
@@ -0,0 +1,6 @@
1
+ """KillerSudoku puzzle game module."""
2
+
3
+ from .config import KillerSudokuConfig
4
+ from .game import KillerSudokuGame
5
+
6
+ __all__ = ["KillerSudokuGame", "KillerSudokuConfig"]
@@ -0,0 +1,23 @@
1
+ """Configuration for Killer Sudoku game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class KillerSudokuConfig(BaseModel):
9
+ """Configuration for Killer Sudoku game."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
12
+ num_cages: int = Field(ge=15, le=35, description="Number of cages")
13
+
14
+ @classmethod
15
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "KillerSudokuConfig":
16
+ """Create config from difficulty level."""
17
+ config_map = {
18
+ DifficultyLevel.EASY: {"num_cages": 20},
19
+ DifficultyLevel.MEDIUM: {"num_cages": 25},
20
+ DifficultyLevel.HARD: {"num_cages": 30},
21
+ }
22
+ params = config_map[difficulty]
23
+ return cls(difficulty=difficulty, **params)