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,502 @@
1
+ """Killer Sudoku 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 KillerSudokuConfig
8
+ from .models import Cage
9
+
10
+
11
+ class KillerSudokuGame(PuzzleGame):
12
+ """Killer Sudoku puzzle game.
13
+
14
+ Combination of Sudoku and Kakuro - fill grid with 1-9
15
+ where regions sum to target values.
16
+ """
17
+
18
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
19
+ """Initialize a new Killer Sudoku game.
20
+
21
+ Args:
22
+ difficulty: Game difficulty level (easy/medium/hard)
23
+ """
24
+ super().__init__(difficulty, seed, **kwargs)
25
+
26
+ self.config = KillerSudokuConfig.from_difficulty(self.difficulty)
27
+ self.size = 9
28
+ self.grid = [[0 for _ in range(9)] for _ in range(9)]
29
+ self.solution = [[0 for _ in range(9)] for _ in range(9)]
30
+ self.initial_grid = [[0 for _ in range(9)] for _ in range(9)]
31
+
32
+ # Cages: list of Cage objects
33
+ self.cages: list[Cage] = []
34
+
35
+ @property
36
+ def name(self) -> str:
37
+ """The display name of this puzzle type."""
38
+ return "Killer Sudoku"
39
+
40
+ @property
41
+ def description(self) -> str:
42
+ """A one-line description of this puzzle type."""
43
+ return "Sudoku + Kakuro - regions must sum to targets"
44
+
45
+ @property
46
+ def constraint_types(self) -> list[str]:
47
+ """Constraint types demonstrated by this puzzle."""
48
+ return ["all_different", "cage_sums", "linear_constraints", "uniqueness"]
49
+
50
+ @property
51
+ def business_analogies(self) -> list[str]:
52
+ """Business problems this puzzle models."""
53
+ return ["grouped_constraints", "sum_budgeting", "allocation_with_quotas"]
54
+
55
+ @property
56
+ def complexity_profile(self) -> dict[str, str]:
57
+ """Complexity profile of this puzzle."""
58
+ return {"reasoning_type": "deductive", "search_space": "large", "constraint_density": "dense"}
59
+
60
+ @property
61
+ def optimal_steps(self) -> int | None:
62
+ """Minimum steps = empty cells to fill."""
63
+ return sum(1 for r in range(9) for c in range(9) if self.grid[r][c] == 0)
64
+
65
+ @property
66
+ def difficulty_profile(self) -> "DifficultyProfile":
67
+ """Difficulty characteristics for Killer Sudoku."""
68
+ from ...models import DifficultyLevel
69
+
70
+ empty = self.optimal_steps or 0
71
+ logic_depth = {
72
+ DifficultyLevel.EASY.value: 3,
73
+ DifficultyLevel.MEDIUM.value: 5,
74
+ DifficultyLevel.HARD.value: 7,
75
+ }.get(self.difficulty.value, 4)
76
+ branching = 2.5 + (empty / 81) * 4
77
+ density = 1.0 - (empty / 81)
78
+ return DifficultyProfile(
79
+ logic_depth=logic_depth,
80
+ branching_factor=round(branching, 1),
81
+ state_observability=1.0,
82
+ constraint_density=round(density, 2),
83
+ )
84
+
85
+ def is_valid_move(self, row: int, col: int, num: int, grid: list[list[int]] | None = None) -> bool:
86
+ """Check if placing num at (row, col) is valid.
87
+
88
+ Args:
89
+ row: Row index (0-indexed)
90
+ col: Column index (0-indexed)
91
+ num: Number to place (1-9)
92
+ grid: Grid to check against (defaults to self.grid)
93
+
94
+ Returns:
95
+ True if the move is valid, False otherwise
96
+ """
97
+ if grid is None:
98
+ grid = self.grid
99
+
100
+ # Check row uniqueness
101
+ for c in range(9):
102
+ if c != col and grid[row][c] == num:
103
+ return False
104
+
105
+ # Check column uniqueness
106
+ for r in range(9):
107
+ if r != row and grid[r][col] == num:
108
+ return False
109
+
110
+ # Check 3x3 box uniqueness
111
+ box_row, box_col = 3 * (row // 3), 3 * (col // 3)
112
+ for r in range(box_row, box_row + 3):
113
+ for c in range(box_col, box_col + 3):
114
+ if (r != row or c != col) and grid[r][c] == num:
115
+ return False
116
+
117
+ return True
118
+
119
+ def solve(self, grid: list[list[int]]) -> bool:
120
+ """Solve the Killer Sudoku puzzle using backtracking.
121
+
122
+ Args:
123
+ grid: The Killer Sudoku grid to solve
124
+
125
+ Returns:
126
+ True if solved, False otherwise
127
+ """
128
+ for row in range(9):
129
+ for col in range(9):
130
+ if grid[row][col] == 0:
131
+ for num in range(1, 10):
132
+ if self.is_valid_move(row, col, num, grid):
133
+ grid[row][col] = num
134
+
135
+ # Check cage constraints
136
+ if self._check_cage_constraints(grid, row, col):
137
+ if self.solve(grid):
138
+ return True
139
+
140
+ grid[row][col] = 0
141
+
142
+ return False
143
+ return True
144
+
145
+ def _check_cage_constraints(self, grid: list[list[int]], row: int, col: int) -> bool:
146
+ """Check if cage constraints are satisfied.
147
+
148
+ Args:
149
+ grid: Current grid state
150
+ row: Row of the cell that was just filled
151
+ col: Column of the cell that was just filled
152
+
153
+ Returns:
154
+ True if cage constraints are satisfied or could be satisfied
155
+ """
156
+ # Find which cage contains this cell
157
+ for cage in self.cages:
158
+ if (row, col) not in cage.cells:
159
+ continue
160
+
161
+ # Get all values in the cage
162
+ cage_values = []
163
+ filled_count = 0
164
+ for r, c in cage.cells:
165
+ val = grid[r][c]
166
+ if val != 0:
167
+ cage_values.append(val)
168
+ filled_count += 1
169
+
170
+ # Check for duplicates within cage
171
+ if len(cage_values) != len(set(cage_values)):
172
+ return False
173
+
174
+ # If cage is not fully filled, check if we haven't exceeded target
175
+ if filled_count < len(cage.cells):
176
+ current_sum = sum(cage_values)
177
+ if current_sum >= cage.target:
178
+ return False
179
+ else:
180
+ # All cells filled - check if sum matches target
181
+ if sum(cage_values) != cage.target:
182
+ return False
183
+
184
+ return True
185
+
186
+ def _generate_cages(self) -> None:
187
+ """Generate cages for the puzzle.
188
+
189
+ In Killer Sudoku, each cage must have unique values.
190
+ """
191
+ used = [[False for _ in range(9)] for _ in range(9)]
192
+ self.cages = []
193
+
194
+ for row in range(9):
195
+ for col in range(9):
196
+ if used[row][col]:
197
+ continue
198
+
199
+ # Start a new cage
200
+ cage_size = self._rng.randint(2, 4) # 2-4 cells per cage
201
+ cells = [(row, col)]
202
+ cage_values = {self.solution[row][col]}
203
+ used[row][col] = True
204
+
205
+ # Try to add more cells (must have unique values)
206
+ for _ in range(cage_size - 1):
207
+ # Find adjacent unused cells with unique values
208
+ candidates = []
209
+ for r, c in cells:
210
+ for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
211
+ nr, nc = r + dr, c + dc
212
+ if 0 <= nr < 9 and 0 <= nc < 9 and not used[nr][nc]:
213
+ if (nr, nc) not in candidates:
214
+ # Check if value is unique in this cage
215
+ if self.solution[nr][nc] not in cage_values:
216
+ candidates.append((nr, nc))
217
+
218
+ if candidates:
219
+ nr, nc = self._rng.choice(candidates)
220
+ cells.append((nr, nc))
221
+ cage_values.add(self.solution[nr][nc])
222
+ used[nr][nc] = True
223
+ else:
224
+ # No valid adjacent cells available
225
+ break
226
+
227
+ # If cage is still size 1, try to merge with an adjacent cage
228
+ if len(cells) == 1:
229
+ r, c = cells[0]
230
+ cell_value = self.solution[r][c]
231
+ merged = False
232
+ for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
233
+ nr, nc = r + dr, c + dc
234
+ if 0 <= nr < 9 and 0 <= nc < 9:
235
+ for cage_idx, cage in enumerate(self.cages):
236
+ if (nr, nc) in cage.cells:
237
+ # Check if we can merge without duplicating values
238
+ cage_vals = {self.solution[cr][cc] for cr, cc in cage.cells}
239
+ if cell_value not in cage_vals:
240
+ new_cells = list(cage.cells)
241
+ new_cells.append((r, c))
242
+ new_sum = sum(self.solution[cr][cc] for cr, cc in new_cells)
243
+ self.cages[cage_idx] = Cage(cells=new_cells, operation=None, target=new_sum)
244
+ merged = True
245
+ break
246
+ if merged:
247
+ break
248
+
249
+ if not merged:
250
+ # Couldn't merge, add as size-1 cage
251
+ target_sum = self.solution[r][c]
252
+ self.cages.append(Cage(cells=cells, operation=None, target=target_sum))
253
+ else:
254
+ # Calculate target sum from solution
255
+ target_sum = sum(self.solution[r][c] for r, c in cells)
256
+ self.cages.append(Cage(cells=cells, operation=None, target=target_sum))
257
+
258
+ async def generate_puzzle(self) -> None:
259
+ """Generate a new Killer Sudoku puzzle."""
260
+ # Generate a valid Sudoku solution
261
+ self.grid = [[0 for _ in range(9)] for _ in range(9)]
262
+
263
+ # Base valid Sudoku pattern
264
+ for row in range(9):
265
+ for col in range(9):
266
+ self.grid[row][col] = (row * 3 + row // 3 + col) % 9 + 1
267
+
268
+ # Shuffle rows within bands and columns within stacks to maintain validity
269
+ for band in range(3):
270
+ # Shuffle rows within this band
271
+ rows_in_band = [band * 3, band * 3 + 1, band * 3 + 2]
272
+ shuffled_rows = rows_in_band[:]
273
+ self._rng.shuffle(shuffled_rows)
274
+ # Swap rows
275
+ temp = [self.grid[shuffled_rows[0]][:], self.grid[shuffled_rows[1]][:], self.grid[shuffled_rows[2]][:]]
276
+ for i, r in enumerate(rows_in_band):
277
+ self.grid[r] = temp[i]
278
+
279
+ for stack in range(3):
280
+ # Shuffle columns within this stack
281
+ cols_in_stack = [stack * 3, stack * 3 + 1, stack * 3 + 2]
282
+ shuffled_cols = cols_in_stack[:]
283
+ self._rng.shuffle(shuffled_cols)
284
+ # Swap columns
285
+ for row in range(9):
286
+ col_temp = [
287
+ self.grid[row][shuffled_cols[0]],
288
+ self.grid[row][shuffled_cols[1]],
289
+ self.grid[row][shuffled_cols[2]],
290
+ ]
291
+ for i, c in enumerate(cols_in_stack):
292
+ self.grid[row][c] = col_temp[i]
293
+
294
+ # Also shuffle digit mapping for more variety
295
+ digit_map = list(range(1, 10))
296
+ self._rng.shuffle(digit_map)
297
+ for row in range(9):
298
+ for col in range(9):
299
+ self.grid[row][col] = digit_map[self.grid[row][col] - 1]
300
+
301
+ self.solution = [row[:] for row in self.grid]
302
+
303
+ # Generate cages
304
+ self._generate_cages()
305
+
306
+ # Empty the grid (Killer Sudoku starts completely empty)
307
+ self.grid = [[0 for _ in range(9)] for _ in range(9)]
308
+ self.initial_grid = [row[:] for row in self.grid]
309
+ self.moves_made = 0
310
+ self.game_started = True
311
+
312
+ async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
313
+ """Place a number on the grid.
314
+
315
+ Args:
316
+ row: Row index (1-indexed, user-facing)
317
+ col: Column index (1-indexed, user-facing)
318
+ num: Number to place (1-9, or 0 to clear)
319
+
320
+ Returns:
321
+ MoveResult indicating success or failure
322
+ """
323
+ # Convert to 0-indexed
324
+ row -= 1
325
+ col -= 1
326
+
327
+ # Validate coordinates
328
+ if not (0 <= row < 9 and 0 <= col < 9):
329
+ return MoveResult(success=False, message="Invalid coordinates. Use row and column between 1-9.")
330
+
331
+ # Clear the cell
332
+ if num == 0:
333
+ self.grid[row][col] = 0
334
+ return MoveResult(success=True, message="Cell cleared.", state_changed=True)
335
+
336
+ # Validate number
337
+ if not (1 <= num <= 9):
338
+ return MoveResult(success=False, message="Invalid number. Use 1-9 or 0 to clear.")
339
+
340
+ # Check if the move is valid
341
+ old_value = self.grid[row][col]
342
+ self.grid[row][col] = num
343
+
344
+ if not self.is_valid_move(row, col, num):
345
+ self.grid[row][col] = old_value
346
+ return MoveResult(
347
+ success=False, message="Invalid move! This number already exists in the row, column, or box."
348
+ )
349
+
350
+ self.moves_made += 1
351
+ return MoveResult(success=True, message="Number placed successfully!", state_changed=True)
352
+
353
+ def is_complete(self) -> bool:
354
+ """Check if the puzzle is complete and correct."""
355
+ # Check all cells filled
356
+ for row in range(9):
357
+ for col in range(9):
358
+ if self.grid[row][col] == 0:
359
+ return False
360
+
361
+ # Check Sudoku constraints (rows, columns, boxes)
362
+ for row in range(9):
363
+ if len(set(self.grid[row])) != 9:
364
+ return False
365
+
366
+ for col in range(9):
367
+ column = [self.grid[row][col] for row in range(9)]
368
+ if len(set(column)) != 9:
369
+ return False
370
+
371
+ for box_row in range(3):
372
+ for box_col in range(3):
373
+ box = []
374
+ for r in range(box_row * 3, box_row * 3 + 3):
375
+ for c in range(box_col * 3, box_col * 3 + 3):
376
+ box.append(self.grid[r][c])
377
+ if len(set(box)) != 9:
378
+ return False
379
+
380
+ # Check all cages
381
+ for cage in self.cages:
382
+ cage_values = [self.grid[r][c] for r, c in cage.cells]
383
+ # Check for duplicates
384
+ if len(cage_values) != len(set(cage_values)):
385
+ return False
386
+ # Check sum
387
+ if sum(cage_values) != cage.target:
388
+ return False
389
+
390
+ return True
391
+
392
+ async def get_hint(self) -> tuple[Any, str] | None:
393
+ """Get a hint for the next move.
394
+
395
+ Returns:
396
+ Tuple of (hint_data, hint_message) or None if puzzle is complete
397
+ """
398
+ empty_cells = [(r, c) for r in range(9) for c in range(9) if self.grid[r][c] == 0]
399
+ if not empty_cells:
400
+ return None
401
+
402
+ row, col = self._rng.choice(empty_cells)
403
+ hint_data = (row + 1, col + 1, self.solution[row][col])
404
+ hint_message = f"Try placing {self.solution[row][col]} at row {row + 1}, column {col + 1}"
405
+ return hint_data, hint_message
406
+
407
+ def render_grid(self) -> str:
408
+ """Render the current puzzle state as ASCII art.
409
+
410
+ Returns:
411
+ String representation of the puzzle grid with cages
412
+ """
413
+ lines = []
414
+
415
+ # Create a cage ID map
416
+ cage_map = {}
417
+ for cage_id, cage in enumerate(self.cages):
418
+ for r, c in cage.cells:
419
+ cage_map[(r, c)] = (cage_id, cage.target)
420
+
421
+ # Header
422
+ lines.append(" | 1 2 3 | 4 5 6 | 7 8 9 |")
423
+ lines.append(" +" + "-" * 7 + "+" + "-" * 7 + "+" + "-" * 7 + "+")
424
+
425
+ for row in range(9):
426
+ if row > 0 and row % 3 == 0:
427
+ lines.append(" +" + "-" * 7 + "+" + "-" * 7 + "+" + "-" * 7 + "+")
428
+
429
+ line = f"{row + 1} |"
430
+ for col in range(9):
431
+ if col > 0 and col % 3 == 0:
432
+ line += " |"
433
+
434
+ cell = self.grid[row][col]
435
+ if cell == 0:
436
+ # Show cage sum in top-left cell of each cage
437
+ cage_id, target_sum = cage_map.get((row, col), (None, None))
438
+ if cage_id is not None:
439
+ cage_cells = self.cages[cage_id].cells
440
+ if (row, col) == min(cage_cells):
441
+ line += f" {target_sum:2d}" if target_sum < 100 else f"{target_sum}"
442
+ else:
443
+ line += " ."
444
+ else:
445
+ line += " ."
446
+ else:
447
+ line += f" {cell}"
448
+ line += " |"
449
+ lines.append(line)
450
+
451
+ lines.append(" +" + "-" * 7 + "+" + "-" * 7 + "+" + "-" * 7 + "+")
452
+
453
+ # Show cage info
454
+ lines.append("\nCages (sum targets):")
455
+ for _i, cage in enumerate(self.cages[:10]): # Show first 10
456
+ cells_str = ", ".join(f"({r + 1},{c + 1})" for r, c in sorted(cage.cells)[:3])
457
+ if len(cage.cells) > 3:
458
+ cells_str += "..."
459
+ lines.append(f" {cage.target}: {cells_str}")
460
+ if len(self.cages) > 10:
461
+ lines.append(f" ... and {len(self.cages) - 10} more cages")
462
+
463
+ return "\n".join(lines)
464
+
465
+ def get_rules(self) -> str:
466
+ """Get the rules description for Killer Sudoku.
467
+
468
+ Returns:
469
+ Multi-line string describing the puzzle rules
470
+ """
471
+ return """KILLER SUDOKU RULES:
472
+ - Fill 9×9 grid with digits 1-9
473
+ - No repeats in rows, columns, or 3×3 boxes
474
+ - Numbers in each cage must sum to the target
475
+ - No repeated digits within a cage"""
476
+
477
+ def get_commands(self) -> str:
478
+ """Get the available commands for Killer Sudoku.
479
+
480
+ Returns:
481
+ Multi-line string describing available commands
482
+ """
483
+ return """KILLER SUDOKU COMMANDS:
484
+ place <row> <col> <num> - Place a number (e.g., 'place 1 2 4')
485
+ clear <row> <col> - Clear a cell
486
+ show - Display the current grid
487
+ hint - Get a hint for the next move
488
+ check - Check your progress
489
+ solve - Show the solution (ends game)
490
+ menu - Return to game selection
491
+ quit - Exit the server"""
492
+
493
+ def get_stats(self) -> str:
494
+ """Get current game statistics.
495
+
496
+ Returns:
497
+ String with game stats
498
+ """
499
+ empty = sum(1 for r in range(9) for c in range(9) if self.grid[r][c] == 0)
500
+ return (
501
+ f"Moves made: {self.moves_made} | Empty cells: {empty} | Total cages: {len(self.cages)} | Seed: {self.seed}"
502
+ )
@@ -0,0 +1,15 @@
1
+ """Killer Sudoku game models."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class Cage(BaseModel):
7
+ """A cage in Killer Sudoku game.
8
+
9
+ Unlike KenKen cages, Killer Sudoku cages only use addition.
10
+ """
11
+
12
+ model_config = ConfigDict(frozen=True)
13
+
14
+ cells: list[tuple[int, int]] = Field(min_length=1, description="List of cell coordinates (0-indexed)")
15
+ target: int = Field(description="Target sum for the cage")
@@ -0,0 +1,6 @@
1
+ """Knapsack puzzle game module."""
2
+
3
+ from .config import KnapsackConfig
4
+ from .game import KnapsackGame
5
+
6
+ __all__ = ["KnapsackGame", "KnapsackConfig"]
@@ -0,0 +1,24 @@
1
+ """Configuration for Knapsack game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class KnapsackConfig(BaseModel):
9
+ """Configuration for Knapsack game."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
12
+ num_items: int = Field(ge=1, le=20, description="Number of items")
13
+ max_weight: int = Field(ge=1, description="Maximum knapsack capacity")
14
+
15
+ @classmethod
16
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "KnapsackConfig":
17
+ """Create config from difficulty level."""
18
+ config_map = {
19
+ DifficultyLevel.EASY: {"num_items": 5, "max_weight": 20},
20
+ DifficultyLevel.MEDIUM: {"num_items": 8, "max_weight": 35},
21
+ DifficultyLevel.HARD: {"num_items": 12, "max_weight": 50},
22
+ }
23
+ params = config_map[difficulty]
24
+ return cls(difficulty=difficulty, **params)
@@ -0,0 +1,10 @@
1
+ """Knapsack game enums."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class KnapsackAction(str, Enum):
7
+ """Actions for Knapsack game."""
8
+
9
+ SELECT = "select"
10
+ DESELECT = "deselect"