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,451 @@
1
+ """Hitori puzzle game implementation."""
2
+
3
+ from typing import Any
4
+
5
+ from ...models import DifficultyProfile, MoveResult
6
+ from .._base import PuzzleGame
7
+
8
+
9
+ class HitoriGame(PuzzleGame):
10
+ """Hitori puzzle game.
11
+
12
+ Shade some cells so that:
13
+ - No number appears more than once in any row or column
14
+ - Shaded cells do not touch horizontally or vertically
15
+ - All unshaded cells form a single connected region
16
+ """
17
+
18
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
19
+ """Initialize a new Hitori game.
20
+
21
+ Args:
22
+ difficulty: Game difficulty level (easy, medium, hard)
23
+ """
24
+ super().__init__(difficulty, seed, **kwargs)
25
+
26
+ from ...models import DifficultyLevel
27
+
28
+ # Set grid size based on difficulty
29
+ self.size = {DifficultyLevel.EASY.value: 5, DifficultyLevel.MEDIUM.value: 7, DifficultyLevel.HARD.value: 9}.get(
30
+ self.difficulty.value, 5
31
+ )
32
+
33
+ # Grid stores the numbers
34
+ self.grid: list[list[int]] = [[0 for _ in range(self.size)] for _ in range(self.size)]
35
+
36
+ # Solution stores which cells should be shaded (True = shaded)
37
+ self.solution: list[list[bool]] = [[False for _ in range(self.size)] for _ in range(self.size)]
38
+
39
+ # Player's shading
40
+ self.shaded: list[list[bool]] = [[False for _ in range(self.size)] for _ in range(self.size)]
41
+
42
+ @property
43
+ def name(self) -> str:
44
+ """The display name of this puzzle type."""
45
+ return "Hitori"
46
+
47
+ @property
48
+ def description(self) -> str:
49
+ """A one-line description of this puzzle type."""
50
+ return "Shade cells to eliminate duplicates - no adjacent shaded cells"
51
+
52
+ @property
53
+ def constraint_types(self) -> list[str]:
54
+ """Constraint types demonstrated by this puzzle."""
55
+ return ["all_different", "connectivity", "adjacency", "partition", "elimination"]
56
+
57
+ @property
58
+ def business_analogies(self) -> list[str]:
59
+ """Business problems this puzzle models."""
60
+ return ["conflict_resolution", "network_connectivity", "resource_elimination", "deduplication"]
61
+
62
+ @property
63
+ def complexity_profile(self) -> dict[str, str]:
64
+ """Complexity profile of this puzzle."""
65
+ return {"reasoning_type": "deductive", "search_space": "medium", "constraint_density": "moderate"}
66
+
67
+ @property
68
+ def optimal_steps(self) -> int | None:
69
+ """Minimum steps = cells to shade."""
70
+ if not hasattr(self, "solution") or not self.solution:
71
+ return None
72
+ return sum(1 for r in range(self.size) for c in range(self.size) if self.solution[r][c])
73
+
74
+ @property
75
+ def difficulty_profile(self) -> "DifficultyProfile":
76
+ """Difficulty characteristics for Hitori."""
77
+ from ...models import DifficultyLevel
78
+
79
+ logic_depth = {
80
+ DifficultyLevel.EASY.value: 2,
81
+ DifficultyLevel.MEDIUM.value: 4,
82
+ DifficultyLevel.HARD.value: 5,
83
+ }.get(self.difficulty.value, 3)
84
+ return DifficultyProfile(
85
+ logic_depth=logic_depth,
86
+ branching_factor=2.0, # Shade or not
87
+ state_observability=1.0,
88
+ constraint_density=0.5,
89
+ )
90
+
91
+ def _is_connected(self, grid: list[list[bool]]) -> bool:
92
+ """Check if all unshaded cells are connected.
93
+
94
+ Args:
95
+ grid: Boolean grid where True = shaded, False = unshaded
96
+
97
+ Returns:
98
+ True if all unshaded cells are connected
99
+ """
100
+ # Find first unshaded cell
101
+ start = None
102
+ for r in range(self.size):
103
+ for c in range(self.size):
104
+ if not grid[r][c]:
105
+ start = (r, c)
106
+ break
107
+ if start:
108
+ break
109
+
110
+ if not start:
111
+ return False # All cells are shaded
112
+
113
+ # BFS to find all connected unshaded cells
114
+ visited = set()
115
+ queue = [start]
116
+ visited.add(start)
117
+
118
+ while queue:
119
+ r, c = queue.pop(0)
120
+ for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
121
+ nr, nc = r + dr, c + dc
122
+ if 0 <= nr < self.size and 0 <= nc < self.size:
123
+ if not grid[nr][nc] and (nr, nc) not in visited:
124
+ visited.add((nr, nc))
125
+ queue.append((nr, nc))
126
+
127
+ # Count total unshaded cells
128
+ unshaded_count = sum(1 for r in range(self.size) for c in range(self.size) if not grid[r][c])
129
+
130
+ return len(visited) == unshaded_count
131
+
132
+ def _has_adjacent_shaded(self, row: int, col: int, grid: list[list[bool]]) -> bool:
133
+ """Check if a cell has any adjacent shaded cells.
134
+
135
+ Args:
136
+ row: Row index
137
+ col: Column index
138
+ grid: Boolean grid where True = shaded
139
+
140
+ Returns:
141
+ True if there's an adjacent shaded cell
142
+ """
143
+ for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
144
+ nr, nc = row + dr, col + dc
145
+ if 0 <= nr < self.size and 0 <= nc < self.size:
146
+ if grid[nr][nc]:
147
+ return True
148
+ return False
149
+
150
+ async def generate_puzzle(self) -> None:
151
+ """Generate a new Hitori puzzle with retry logic."""
152
+ max_attempts = 50
153
+
154
+ for _attempt in range(max_attempts):
155
+ # Step 1: Start with a valid Latin square
156
+ for r in range(self.size):
157
+ for c in range(self.size):
158
+ self.grid[r][c] = ((r + c) % self.size) + 1
159
+
160
+ # Step 2: Randomly swap some rows and columns to create variety
161
+ for _ in range(self.size * 2):
162
+ if self._rng.random() < 0.5:
163
+ # Swap two rows
164
+ r1, r2 = self._rng.randint(0, self.size - 1), self._rng.randint(0, self.size - 1)
165
+ self.grid[r1], self.grid[r2] = self.grid[r2], self.grid[r1]
166
+ else:
167
+ # Swap two columns
168
+ c1, c2 = self._rng.randint(0, self.size - 1), self._rng.randint(0, self.size - 1)
169
+ for r in range(self.size):
170
+ self.grid[r][c1], self.grid[r][c2] = self.grid[r][c2], self.grid[r][c1]
171
+
172
+ # Step 3: Create duplicates strategically
173
+ from ...models import DifficultyLevel
174
+
175
+ num_duplicates = {
176
+ DifficultyLevel.EASY.value: self.size,
177
+ DifficultyLevel.MEDIUM.value: self.size * 2,
178
+ DifficultyLevel.HARD.value: self.size * 3,
179
+ }.get(self.difficulty.value, self.size)
180
+
181
+ # Track which cells we've modified to avoid over-duplication
182
+ modified_cells = set()
183
+
184
+ for _ in range(num_duplicates):
185
+ attempts = 0
186
+ while attempts < 20:
187
+ r = self._rng.randint(0, self.size - 1)
188
+ c = self._rng.randint(0, self.size - 1)
189
+
190
+ if (r, c) not in modified_cells:
191
+ # Find a duplicate value in the same row or column
192
+ if self._rng.random() < 0.5:
193
+ # Duplicate in row
194
+ target_c = self._rng.randint(0, self.size - 1)
195
+ if target_c != c:
196
+ self.grid[r][c] = self.grid[r][target_c]
197
+ modified_cells.add((r, c))
198
+ break
199
+ else:
200
+ # Duplicate in column
201
+ target_r = self._rng.randint(0, self.size - 1)
202
+ if target_r != r:
203
+ self.grid[r][c] = self.grid[target_r][c]
204
+ modified_cells.add((r, c))
205
+ break
206
+
207
+ attempts += 1
208
+
209
+ # Step 4: Generate solution
210
+ self._generate_solution()
211
+
212
+ # Step 5: Validate solution
213
+ if self._validate_solution():
214
+ self.game_started = True
215
+ return
216
+
217
+ # Fallback: Generate a simple, always-valid puzzle
218
+ # Reset to a simple latin square with minimal duplicates
219
+ for r in range(self.size):
220
+ for c in range(self.size):
221
+ self.grid[r][c] = ((r + c) % self.size) + 1
222
+ self.solution[r][c] = False
223
+
224
+ # Add exactly one duplicate per row that can be safely shaded
225
+ for r in range(self.size):
226
+ # Find a cell where shading won't break connectivity
227
+ # (never shade edge cells or adjacent to already shaded)
228
+ c = r % (self.size - 2) + 1 # Stay away from edges
229
+ if not self._has_adjacent_shaded(r, c, self.solution):
230
+ # Copy a value to create a duplicate
231
+ self.grid[r][c] = self.grid[r][(c + 1) % self.size]
232
+ self.solution[r][c] = True
233
+
234
+ # Verify fallback is valid
235
+ if not self._validate_solution():
236
+ # Last resort: no duplicates at all (trivial puzzle)
237
+ for r in range(self.size):
238
+ for c in range(self.size):
239
+ self.grid[r][c] = ((r + c) % self.size) + 1
240
+ self.solution[r][c] = False
241
+
242
+ self.game_started = True
243
+
244
+ def _validate_solution(self) -> bool:
245
+ """Validate that the generated solution is solvable."""
246
+ # Check that solution doesn't have adjacent shaded cells
247
+ for r in range(self.size):
248
+ for c in range(self.size):
249
+ if self.solution[r][c] and self._has_adjacent_shaded(r, c, self.solution):
250
+ return False
251
+
252
+ # Check that unshaded cells are connected
253
+ if not self._is_connected(self.solution):
254
+ return False
255
+
256
+ # Check no duplicates in rows (among unshaded cells)
257
+ for r in range(self.size):
258
+ seen = set()
259
+ for c in range(self.size):
260
+ if not self.solution[r][c]:
261
+ val = self.grid[r][c]
262
+ if val in seen:
263
+ return False
264
+ seen.add(val)
265
+
266
+ # Check no duplicates in columns (among unshaded cells)
267
+ for c in range(self.size):
268
+ seen = set()
269
+ for r in range(self.size):
270
+ if not self.solution[r][c]:
271
+ val = self.grid[r][c]
272
+ if val in seen:
273
+ return False
274
+ seen.add(val)
275
+
276
+ return True
277
+
278
+ def _generate_solution(self) -> None:
279
+ """Generate a valid solution for the current grid."""
280
+ # Simple greedy approach: shade cells to eliminate duplicates
281
+ # while maintaining constraints
282
+
283
+ # For each row, find duplicates
284
+ for r in range(self.size):
285
+ row_seen: dict[int, int] = {}
286
+ for c in range(self.size):
287
+ val = self.grid[r][c]
288
+ if val in row_seen:
289
+ # We have a duplicate - shade one of them
290
+ # Choose to shade the current cell if it doesn't violate constraints
291
+ if not self._has_adjacent_shaded(r, c, self.solution):
292
+ self.solution[r][c] = True
293
+ else:
294
+ # Shade the first occurrence instead
295
+ prev_c = row_seen[val]
296
+ if not self._has_adjacent_shaded(r, prev_c, self.solution):
297
+ self.solution[r][prev_c] = True
298
+ else:
299
+ row_seen[val] = c
300
+
301
+ # For each column, find remaining duplicates
302
+ for c in range(self.size):
303
+ col_seen: dict[int, int] = {}
304
+ for r in range(self.size):
305
+ if self.solution[r][c]:
306
+ continue # Already shaded
307
+
308
+ val = self.grid[r][c]
309
+ if val in col_seen:
310
+ # Duplicate - shade if possible
311
+ if not self._has_adjacent_shaded(r, c, self.solution):
312
+ self.solution[r][c] = True
313
+ else:
314
+ prev_r = col_seen[val]
315
+ if not self.solution[prev_r][c] and not self._has_adjacent_shaded(prev_r, c, self.solution):
316
+ self.solution[prev_r][c] = True
317
+ else:
318
+ col_seen[val] = r
319
+
320
+ async def validate_move(self, *args: Any, **kwargs: Any) -> MoveResult:
321
+ """Validate a shading move.
322
+
323
+ Args:
324
+ args[0]: Row (1-indexed)
325
+ args[1]: Column (1-indexed)
326
+ args[2]: Action - 'shade', 'unshade', or 's'/'u'
327
+
328
+ Returns:
329
+ MoveResult containing success status and message
330
+ """
331
+ if len(args) < 3:
332
+ return MoveResult(success=False, message="Usage: place <row> <col> <shade|unshade>")
333
+
334
+ try:
335
+ row, col = int(args[0]) - 1, int(args[1]) - 1
336
+ action = str(args[2]).lower()
337
+ except (ValueError, IndexError):
338
+ return MoveResult(success=False, message="Invalid coordinates or action")
339
+
340
+ # Validate coordinates
341
+ if not (0 <= row < self.size and 0 <= col < self.size):
342
+ return MoveResult(success=False, message=f"Coordinates must be between 1 and {self.size}")
343
+
344
+ # Process action
345
+ if action in ("shade", "s"):
346
+ # Check if shading would create adjacent shaded cells
347
+ if self._has_adjacent_shaded(row, col, self.shaded):
348
+ return MoveResult(success=False, message="Cannot shade - would create adjacent shaded cells")
349
+
350
+ self.shaded[row][col] = True
351
+ self.moves_made += 1
352
+ return MoveResult(success=True, message="Cell shaded")
353
+
354
+ elif action in ("unshade", "u", "clear"):
355
+ self.shaded[row][col] = False
356
+ self.moves_made += 1
357
+ return MoveResult(success=True, message="Cell unshaded")
358
+
359
+ else:
360
+ return MoveResult(success=False, message="Action must be 'shade' or 'unshade'")
361
+
362
+ def is_complete(self) -> bool:
363
+ """Check if the puzzle is completely and correctly solved."""
364
+ # Check no duplicates in rows
365
+ for r in range(self.size):
366
+ seen = set()
367
+ for c in range(self.size):
368
+ if not self.shaded[r][c]:
369
+ val = self.grid[r][c]
370
+ if val in seen:
371
+ return False
372
+ seen.add(val)
373
+
374
+ # Check no duplicates in columns
375
+ for c in range(self.size):
376
+ seen = set()
377
+ for r in range(self.size):
378
+ if not self.shaded[r][c]:
379
+ val = self.grid[r][c]
380
+ if val in seen:
381
+ return False
382
+ seen.add(val)
383
+
384
+ # Check no adjacent shaded cells
385
+ for r in range(self.size):
386
+ for c in range(self.size):
387
+ if self.shaded[r][c] and self._has_adjacent_shaded(r, c, self.shaded):
388
+ return False
389
+
390
+ # Check all unshaded cells are connected
391
+ if not self._is_connected(self.shaded):
392
+ return False
393
+
394
+ return True
395
+
396
+ async def get_hint(self) -> tuple[Any, str] | None:
397
+ """Get a hint for the next move."""
398
+ # Find a cell that should be shaded but isn't, or vice versa
399
+ for r in range(self.size):
400
+ for c in range(self.size):
401
+ if self.solution[r][c] and not self.shaded[r][c]:
402
+ return ((r + 1, c + 1, "shade"), f"Try shading cell at row {r + 1}, column {c + 1}")
403
+ elif not self.solution[r][c] and self.shaded[r][c]:
404
+ return ((r + 1, c + 1, "unshade"), f"Try unshading cell at row {r + 1}, column {c + 1}")
405
+
406
+ return None
407
+
408
+ def render_grid(self) -> str:
409
+ """Render the current puzzle state as ASCII art."""
410
+ lines = []
411
+
412
+ # Header
413
+ header = " |"
414
+ for c in range(self.size):
415
+ header += f" {c + 1}"
416
+ lines.append(header)
417
+ lines.append(" +" + "--" * self.size)
418
+
419
+ # Grid rows
420
+ for r in range(self.size):
421
+ row = f"{r + 1:2d}|"
422
+ for c in range(self.size):
423
+ if self.shaded[r][c]:
424
+ row += f"#{self.grid[r][c]}"
425
+ else:
426
+ row += f" {self.grid[r][c]}"
427
+ lines.append(row)
428
+
429
+ return "\n".join(lines)
430
+
431
+ def get_rules(self) -> str:
432
+ """Get the rules description for this puzzle type."""
433
+ return """HITORI RULES:
434
+ - Shade some cells so that:
435
+ * No number appears more than once in any row
436
+ * No number appears more than once in any column
437
+ * Shaded cells do not touch horizontally or vertically
438
+ * All unshaded cells form a single connected region
439
+ - Shaded cells are shown with # prefix"""
440
+
441
+ def get_commands(self) -> str:
442
+ """Get the available commands for this puzzle type."""
443
+ return """HITORI COMMANDS:
444
+ place <row> <col> shade - Shade a cell
445
+ place <row> <col> unshade - Unshade a cell
446
+ Example: place 1 3 shade
447
+ hint - Get a hint for the next move
448
+ check - Check if puzzle is complete
449
+ solve - Show the solution
450
+ menu - Return to main menu
451
+ quit - Exit the game"""
@@ -0,0 +1,6 @@
1
+ """Kakuro puzzle game module."""
2
+
3
+ from .config import KakuroConfig
4
+ from .game import KakuroGame
5
+
6
+ __all__ = ["KakuroGame", "KakuroConfig"]
@@ -0,0 +1,24 @@
1
+ """Configuration for Kakuro game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class KakuroConfig(BaseModel):
9
+ """Configuration for Kakuro game."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
12
+ size: int = Field(ge=4, le=10, description="Grid size")
13
+ num_runs: int = Field(ge=1, description="Number of runs (horizontal + vertical)")
14
+
15
+ @classmethod
16
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "KakuroConfig":
17
+ """Create config from difficulty level."""
18
+ config_map = {
19
+ DifficultyLevel.EASY: {"size": 4, "num_runs": 6},
20
+ DifficultyLevel.MEDIUM: {"size": 6, "num_runs": 10},
21
+ DifficultyLevel.HARD: {"size": 8, "num_runs": 16},
22
+ }
23
+ params = config_map[difficulty]
24
+ return cls(difficulty=difficulty, **params)