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,516 @@
1
+ """Fillomino 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 FillominoConfig
8
+
9
+
10
+ class FillominoGame(PuzzleGame):
11
+ """Fillomino puzzle game.
12
+
13
+ Fill the grid with numbers such that:
14
+ - The grid is divided into polyomino regions
15
+ - Each region contains cells with the same number
16
+ - The number in each region equals the size of that region
17
+ - No two regions of the same size can share an edge
18
+ """
19
+
20
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
21
+ """Initialize a new Fillomino game.
22
+
23
+ Args:
24
+ difficulty: Game difficulty level (easy=6x6, medium=8x8, hard=10x10)
25
+ """
26
+ super().__init__(difficulty, seed, **kwargs)
27
+
28
+ # Use pydantic config based on difficulty
29
+ self.config = FillominoConfig.from_difficulty(self.difficulty)
30
+ self.size = self.config.size
31
+
32
+ # Grid: 0 = empty, 1-9 = number
33
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
34
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
35
+ self.initial_grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
36
+
37
+ @property
38
+ def name(self) -> str:
39
+ """The display name of this puzzle type."""
40
+ return "Fillomino"
41
+
42
+ @property
43
+ def description(self) -> str:
44
+ """A one-line description of this puzzle type."""
45
+ return "Region growth puzzle - divide grid into numbered polyominoes"
46
+
47
+ @property
48
+ def constraint_types(self) -> list[str]:
49
+ """Constraint types demonstrated by this puzzle."""
50
+ return ["region_growth", "self_referential_constraints", "partition", "adjacency_exclusion"]
51
+
52
+ @property
53
+ def business_analogies(self) -> list[str]:
54
+ """Business problems this puzzle models."""
55
+ return ["territory_expansion", "cluster_formation", "resource_grouping", "zoning"]
56
+
57
+ @property
58
+ def complexity_profile(self) -> dict[str, str]:
59
+ """Complexity profile of this puzzle."""
60
+ return {"reasoning_type": "deductive", "search_space": "large", "constraint_density": "dense"}
61
+
62
+ @property
63
+ def optimal_steps(self) -> int | None:
64
+ """Minimum steps = empty cells to fill."""
65
+ return sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
66
+
67
+ @property
68
+ def difficulty_profile(self) -> "DifficultyProfile":
69
+ """Difficulty characteristics for Fillomino."""
70
+ from ...models import DifficultyLevel
71
+
72
+ empty = self.optimal_steps or 0
73
+ total = self.size * self.size
74
+ logic_depth = {
75
+ DifficultyLevel.EASY.value: 2,
76
+ DifficultyLevel.MEDIUM.value: 4,
77
+ DifficultyLevel.HARD.value: 5,
78
+ }.get(self.difficulty.value, 3)
79
+ branching = 3.0 + (empty / total) * 3
80
+ density = 1.0 - (empty / total) if total > 0 else 0.5
81
+ return DifficultyProfile(
82
+ logic_depth=logic_depth,
83
+ branching_factor=round(branching, 1),
84
+ state_observability=1.0,
85
+ constraint_density=round(density, 2),
86
+ )
87
+
88
+ def _get_adjacent(self, row: int, col: int) -> list[tuple[int, int]]:
89
+ """Get orthogonally adjacent cells.
90
+
91
+ Args:
92
+ row: Row index
93
+ col: Column index
94
+
95
+ Returns:
96
+ List of (row, col) tuples for valid adjacent cells
97
+ """
98
+ adjacent = []
99
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
100
+ nr, nc = row + dr, col + dc
101
+ if 0 <= nr < self.size and 0 <= nc < self.size:
102
+ adjacent.append((nr, nc))
103
+ return adjacent
104
+
105
+ def _find_region(
106
+ self, grid: list[list[int]], row: int, col: int, visited: set[tuple[int, int]]
107
+ ) -> list[tuple[int, int]]:
108
+ """Find all cells in the same region using flood fill.
109
+
110
+ Args:
111
+ grid: Grid to search
112
+ row: Starting row
113
+ col: Starting column
114
+ visited: Set of already visited cells
115
+
116
+ Returns:
117
+ List of (row, col) tuples in the region
118
+ """
119
+ if (row, col) in visited:
120
+ return []
121
+
122
+ target_value = grid[row][col]
123
+ if target_value == 0:
124
+ return []
125
+
126
+ region = []
127
+ stack = [(row, col)]
128
+
129
+ while stack:
130
+ r, c = stack.pop()
131
+ if (r, c) in visited:
132
+ continue
133
+ if grid[r][c] != target_value:
134
+ continue
135
+
136
+ visited.add((r, c))
137
+ region.append((r, c))
138
+
139
+ for nr, nc in self._get_adjacent(r, c):
140
+ if (nr, nc) not in visited and grid[nr][nc] == target_value:
141
+ stack.append((nr, nc))
142
+
143
+ return region
144
+
145
+ def _create_fallback_solution(self) -> None:
146
+ """Create a simple valid Fillomino solution.
147
+
148
+ Uses a greedy approach: for each empty cell, try to create the largest
149
+ valid region possible (up to size 4), then fill remaining with 1s.
150
+ """
151
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
152
+
153
+ # Process cells in order, trying to create valid regions
154
+ for r in range(self.size):
155
+ for c in range(self.size):
156
+ if self.solution[r][c] != 0:
157
+ continue
158
+
159
+ # Try to create a region of size 2, 3, or 4
160
+ placed = False
161
+ for target_size in [3, 2, 4]:
162
+ region = self._try_grow_region(r, c, target_size)
163
+ if region and len(region) == target_size:
164
+ for rr, cc in region:
165
+ self.solution[rr][cc] = target_size
166
+ placed = True
167
+ break
168
+
169
+ if not placed:
170
+ # Can't form a larger region, use size 1
171
+ # But check it won't merge with adjacent 1s
172
+ can_use_one = True
173
+ for nr, nc in self._get_adjacent(r, c):
174
+ if self.solution[nr][nc] == 1:
175
+ can_use_one = False
176
+ break
177
+
178
+ if can_use_one:
179
+ self.solution[r][c] = 1
180
+ else:
181
+ # Try size 2 with any adjacent empty cell
182
+ for nr, nc in self._get_adjacent(r, c):
183
+ if self.solution[nr][nc] == 0:
184
+ self.solution[r][c] = 2
185
+ self.solution[nr][nc] = 2
186
+ placed = True
187
+ break
188
+ if not placed:
189
+ # Last resort
190
+ self.solution[r][c] = 1
191
+
192
+ def _try_grow_region(self, start_r: int, start_c: int, target_size: int) -> list[tuple[int, int]] | None:
193
+ """Try to grow a region of exactly target_size from start position."""
194
+ region = [(start_r, start_c)]
195
+
196
+ while len(region) < target_size:
197
+ candidates = []
198
+ for r, c in region:
199
+ for nr, nc in self._get_adjacent(r, c):
200
+ if self.solution[nr][nc] == 0 and (nr, nc) not in region:
201
+ # Check this wouldn't create adjacency with same-size region
202
+ test_region = region + [(nr, nc)]
203
+ valid = True
204
+ for tr, tc in test_region:
205
+ for ar, ac in self._get_adjacent(tr, tc):
206
+ if (ar, ac) not in test_region and self.solution[ar][ac] == target_size:
207
+ valid = False
208
+ break
209
+ if not valid:
210
+ break
211
+ if valid:
212
+ candidates.append((nr, nc))
213
+
214
+ if not candidates:
215
+ return None
216
+
217
+ # Pick the first candidate
218
+ region.append(candidates[0])
219
+
220
+ return region
221
+
222
+ def _is_valid_region_placement(self, grid: list[list[int]], region: list[tuple[int, int]], size: int) -> bool:
223
+ """Check if placing a region with given size would be valid.
224
+
225
+ A region is valid if no adjacent cell outside the region has the same number.
226
+ """
227
+ for r, c in region:
228
+ for nr, nc in self._get_adjacent(r, c):
229
+ if (nr, nc) not in region and grid[nr][nc] == size:
230
+ return False
231
+ return True
232
+
233
+ async def generate_puzzle(self) -> None:
234
+ """Generate a new Fillomino puzzle with valid solution."""
235
+ max_attempts = 50
236
+
237
+ for _attempt in range(max_attempts):
238
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
239
+
240
+ # Fill the grid with regions
241
+ success = True
242
+ for _ in range(self.size * self.size):
243
+ # Find empty cells
244
+ empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.solution[r][c] == 0]
245
+ if not empty_cells:
246
+ break
247
+
248
+ # Pick a random empty cell
249
+ r, c = self._rng.choice(empty_cells)
250
+
251
+ # Try different region sizes, starting from larger
252
+ placed = False
253
+ for target_size in self._rng.sample(range(1, 6), min(5, len(empty_cells))):
254
+ region = [(r, c)]
255
+
256
+ # Grow the region
257
+ temp_solution = [row[:] for row in self.solution]
258
+ temp_solution[r][c] = target_size
259
+
260
+ while len(region) < target_size:
261
+ # Find valid candidates
262
+ candidates = []
263
+ for rr, cc in region:
264
+ for nr, nc in self._get_adjacent(rr, cc):
265
+ if temp_solution[nr][nc] == 0 and (nr, nc) not in region:
266
+ # Check if adding this cell would create adjacent same-size regions
267
+ test_region = region + [(nr, nc)]
268
+ if self._is_valid_region_placement(temp_solution, test_region, target_size):
269
+ candidates.append((nr, nc))
270
+
271
+ if not candidates:
272
+ break
273
+
274
+ nr, nc = self._rng.choice(candidates)
275
+ region.append((nr, nc))
276
+ temp_solution[nr][nc] = target_size
277
+
278
+ # Check if we achieved the target size and the region is valid
279
+ if len(region) == target_size and self._is_valid_region_placement(
280
+ temp_solution, region, target_size
281
+ ):
282
+ # Apply the region
283
+ for rr, cc in region:
284
+ self.solution[rr][cc] = target_size
285
+ placed = True
286
+ break
287
+
288
+ if not placed:
289
+ # Try size 1 as fallback
290
+ if self._is_valid_region_placement(self.solution, [(r, c)], 1):
291
+ self.solution[r][c] = 1
292
+ else:
293
+ # Can't place anything valid here
294
+ success = False
295
+ break
296
+
297
+ if success:
298
+ # Verify the solution is complete and valid
299
+ if self._verify_solution():
300
+ break
301
+
302
+ # If no valid solution found, create a simple valid fallback
303
+ if not self._verify_solution():
304
+ self._create_fallback_solution()
305
+
306
+ # Create the puzzle by revealing some numbers
307
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
308
+ num_clues = self.config.num_clues
309
+
310
+ # Reveal one number from each region
311
+ visited: set[tuple[int, int]] = set()
312
+ clue_count = 0
313
+ for r in range(self.size):
314
+ for c in range(self.size):
315
+ if (r, c) not in visited and clue_count < num_clues:
316
+ region = self._find_region(self.solution, r, c, set())
317
+ if region:
318
+ reveal_r, reveal_c = self._rng.choice(region)
319
+ self.grid[reveal_r][reveal_c] = self.solution[reveal_r][reveal_c]
320
+ clue_count += 1
321
+ visited.update(region)
322
+
323
+ self.initial_grid = [row[:] for row in self.grid]
324
+ self.moves_made = 0
325
+ self.game_started = True
326
+
327
+ def _verify_solution(self) -> bool:
328
+ """Verify the solution is complete and valid."""
329
+ # Check all cells are filled
330
+ for r in range(self.size):
331
+ for c in range(self.size):
332
+ if self.solution[r][c] == 0:
333
+ return False
334
+
335
+ # Check each region
336
+ visited: set[tuple[int, int]] = set()
337
+ for r in range(self.size):
338
+ for c in range(self.size):
339
+ if (r, c) in visited:
340
+ continue
341
+
342
+ region = self._find_region(self.solution, r, c, set())
343
+ if not region:
344
+ return False
345
+
346
+ # Check region size matches the number
347
+ size = len(region)
348
+ number = self.solution[r][c]
349
+ if size != number:
350
+ return False
351
+
352
+ # Check no adjacent region has the same size
353
+ if not self._is_valid_region_placement(self.solution, region, number):
354
+ return False
355
+
356
+ visited.update(region)
357
+
358
+ return True
359
+
360
+ async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
361
+ """Place a number on the grid.
362
+
363
+ Args:
364
+ row: Row index (1-indexed, user-facing)
365
+ col: Column index (1-indexed, user-facing)
366
+ num: Number to place (1-9, or 0 to clear)
367
+
368
+ Returns:
369
+ MoveResult with success status and message
370
+ """
371
+ # Convert to 0-indexed
372
+ row -= 1
373
+ col -= 1
374
+
375
+ # Validate coordinates
376
+ if not (0 <= row < self.size and 0 <= col < self.size):
377
+ return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
378
+
379
+ # Check if this cell is part of the initial puzzle
380
+ if self.initial_grid[row][col] != 0:
381
+ return MoveResult(success=False, message="Cannot modify initial clue cells.")
382
+
383
+ # Clear the cell
384
+ if num == 0:
385
+ self.grid[row][col] = 0
386
+ return MoveResult(success=True, message="Cell cleared.", state_changed=True)
387
+
388
+ # Validate number range
389
+ if not (1 <= num <= 9):
390
+ return MoveResult(success=False, message="Invalid number. Use 1-9 or 0 to clear.")
391
+
392
+ # Place the number
393
+ self.grid[row][col] = num
394
+ self.moves_made += 1
395
+ return MoveResult(success=True, message="Number placed successfully!", state_changed=True)
396
+
397
+ def is_complete(self) -> bool:
398
+ """Check if the puzzle is complete and correct."""
399
+ # Check all cells are filled
400
+ for row in range(self.size):
401
+ for col in range(self.size):
402
+ if self.grid[row][col] == 0:
403
+ return False
404
+
405
+ # Check each region
406
+ visited = set()
407
+ for r in range(self.size):
408
+ for c in range(self.size):
409
+ if (r, c) in visited:
410
+ continue
411
+
412
+ region = self._find_region(self.grid, r, c, set())
413
+ if not region:
414
+ return False
415
+
416
+ # Check region size matches the number
417
+ size = len(region)
418
+ number = self.grid[r][c]
419
+ if size != number:
420
+ return False
421
+
422
+ # Check no adjacent region has the same size
423
+ for rr, cc in region:
424
+ for nr, nc in self._get_adjacent(rr, cc):
425
+ if (nr, nc) not in region and self.grid[nr][nc] == number:
426
+ return False
427
+
428
+ visited.update(region)
429
+
430
+ return True
431
+
432
+ async def get_hint(self) -> tuple[Any, str] | None:
433
+ """Get a hint for the next move.
434
+
435
+ Returns:
436
+ Tuple of (hint_data, hint_message) or None if puzzle is complete
437
+ """
438
+ # Find an empty cell
439
+ for r in range(self.size):
440
+ for c in range(self.size):
441
+ if self.grid[r][c] == 0:
442
+ correct_num = self.solution[r][c]
443
+ hint_data = (r + 1, c + 1, correct_num)
444
+ hint_message = f"Try placing {correct_num} at row {r + 1}, column {c + 1}"
445
+ return hint_data, hint_message
446
+
447
+ return None
448
+
449
+ def render_grid(self) -> str:
450
+ """Render the current puzzle state as ASCII art.
451
+
452
+ Returns:
453
+ String representation of the puzzle grid
454
+ """
455
+ lines = []
456
+
457
+ # Header
458
+ header = " |"
459
+ for c in range(self.size):
460
+ header += f" {c + 1}"
461
+ lines.append(header)
462
+ lines.append(" +" + "--" * self.size)
463
+
464
+ # Grid rows
465
+ for r in range(self.size):
466
+ row_str = f"{r + 1:2}|"
467
+ for c in range(self.size):
468
+ cell = self.grid[r][c]
469
+ if cell == 0:
470
+ row_str += " ."
471
+ else:
472
+ row_str += f" {cell}"
473
+ lines.append(row_str)
474
+
475
+ lines.append("\nLegend: . = empty, 1-9 = numbers forming regions")
476
+
477
+ return "\n".join(lines)
478
+
479
+ def get_rules(self) -> str:
480
+ """Get the rules description for Fillomino.
481
+
482
+ Returns:
483
+ Multi-line string describing the puzzle rules
484
+ """
485
+ return """FILLOMINO RULES:
486
+ - Fill the grid with numbers to form regions
487
+ - Each region contains cells with the same number
488
+ - The number in each region equals the size (area) of that region
489
+ - No two regions of the same size can share an edge
490
+ - Some numbers are given as clues"""
491
+
492
+ def get_commands(self) -> str:
493
+ """Get the available commands for Fillomino.
494
+
495
+ Returns:
496
+ Multi-line string describing available commands
497
+ """
498
+ return """FILLOMINO COMMANDS:
499
+ place <row> <col> <num> - Place a number (e.g., 'place 1 5 3')
500
+ clear <row> <col> - Clear a cell (same as 'place <row> <col> 0')
501
+ show - Display the current grid
502
+ hint - Get a hint for the next move
503
+ check - Check your progress
504
+ solve - Show the solution (ends game)
505
+ menu - Return to game selection
506
+ quit - Exit the server"""
507
+
508
+ def get_stats(self) -> str:
509
+ """Get current game statistics.
510
+
511
+ Returns:
512
+ String with game stats
513
+ """
514
+ filled = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] != 0)
515
+ total = self.size * self.size
516
+ return f"Moves made: {self.moves_made} | Filled: {filled}/{total} | Seed: {self.seed}"
@@ -0,0 +1,6 @@
1
+ """Futoshiki puzzle game module."""
2
+
3
+ from .config import FutoshikiConfig
4
+ from .game import FutoshikiGame
5
+
6
+ __all__ = ["FutoshikiGame", "FutoshikiConfig"]
@@ -0,0 +1,23 @@
1
+ """Configuration for Futoshiki game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class FutoshikiConfig(BaseModel):
9
+ """Configuration for Futoshiki game."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
12
+ size: int = Field(ge=4, le=9, description="Grid size (NxN)")
13
+
14
+ @classmethod
15
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "FutoshikiConfig":
16
+ """Create config from difficulty level."""
17
+ config_map = {
18
+ DifficultyLevel.EASY: {"size": 4},
19
+ DifficultyLevel.MEDIUM: {"size": 5},
20
+ DifficultyLevel.HARD: {"size": 6},
21
+ }
22
+ params = config_map[difficulty]
23
+ return cls(difficulty=difficulty, **params)