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.
- chuk_puzzles_gym/__init__.py +19 -0
- chuk_puzzles_gym/constants.py +9 -0
- chuk_puzzles_gym/eval.py +763 -0
- chuk_puzzles_gym/export/__init__.py +20 -0
- chuk_puzzles_gym/export/dataset.py +376 -0
- chuk_puzzles_gym/games/__init__.py +94 -0
- chuk_puzzles_gym/games/_base/__init__.py +6 -0
- chuk_puzzles_gym/games/_base/commands.py +91 -0
- chuk_puzzles_gym/games/_base/game.py +337 -0
- chuk_puzzles_gym/games/binary/__init__.py +6 -0
- chuk_puzzles_gym/games/binary/config.py +23 -0
- chuk_puzzles_gym/games/binary/game.py +434 -0
- chuk_puzzles_gym/games/bridges/__init__.py +6 -0
- chuk_puzzles_gym/games/bridges/config.py +24 -0
- chuk_puzzles_gym/games/bridges/game.py +489 -0
- chuk_puzzles_gym/games/einstein/__init__.py +6 -0
- chuk_puzzles_gym/games/einstein/config.py +23 -0
- chuk_puzzles_gym/games/einstein/constants.py +13 -0
- chuk_puzzles_gym/games/einstein/game.py +366 -0
- chuk_puzzles_gym/games/einstein/models.py +35 -0
- chuk_puzzles_gym/games/fillomino/__init__.py +6 -0
- chuk_puzzles_gym/games/fillomino/config.py +24 -0
- chuk_puzzles_gym/games/fillomino/game.py +516 -0
- chuk_puzzles_gym/games/futoshiki/__init__.py +6 -0
- chuk_puzzles_gym/games/futoshiki/config.py +23 -0
- chuk_puzzles_gym/games/futoshiki/game.py +391 -0
- chuk_puzzles_gym/games/hidato/__init__.py +6 -0
- chuk_puzzles_gym/games/hidato/config.py +24 -0
- chuk_puzzles_gym/games/hidato/game.py +403 -0
- chuk_puzzles_gym/games/hitori/__init__.py +6 -0
- chuk_puzzles_gym/games/hitori/config.py +23 -0
- chuk_puzzles_gym/games/hitori/game.py +451 -0
- chuk_puzzles_gym/games/kakuro/__init__.py +6 -0
- chuk_puzzles_gym/games/kakuro/config.py +24 -0
- chuk_puzzles_gym/games/kakuro/game.py +399 -0
- chuk_puzzles_gym/games/kenken/__init__.py +6 -0
- chuk_puzzles_gym/games/kenken/config.py +24 -0
- chuk_puzzles_gym/games/kenken/enums.py +13 -0
- chuk_puzzles_gym/games/kenken/game.py +486 -0
- chuk_puzzles_gym/games/kenken/models.py +15 -0
- chuk_puzzles_gym/games/killer_sudoku/__init__.py +6 -0
- chuk_puzzles_gym/games/killer_sudoku/config.py +23 -0
- chuk_puzzles_gym/games/killer_sudoku/game.py +502 -0
- chuk_puzzles_gym/games/killer_sudoku/models.py +15 -0
- chuk_puzzles_gym/games/knapsack/__init__.py +6 -0
- chuk_puzzles_gym/games/knapsack/config.py +24 -0
- chuk_puzzles_gym/games/knapsack/enums.py +10 -0
- chuk_puzzles_gym/games/knapsack/game.py +340 -0
- chuk_puzzles_gym/games/knapsack/models.py +13 -0
- chuk_puzzles_gym/games/lights_out/__init__.py +6 -0
- chuk_puzzles_gym/games/lights_out/config.py +24 -0
- chuk_puzzles_gym/games/lights_out/game.py +249 -0
- chuk_puzzles_gym/games/logic_grid/__init__.py +6 -0
- chuk_puzzles_gym/games/logic_grid/config.py +24 -0
- chuk_puzzles_gym/games/logic_grid/constants.py +12 -0
- chuk_puzzles_gym/games/logic_grid/game.py +333 -0
- chuk_puzzles_gym/games/logic_grid/models.py +24 -0
- chuk_puzzles_gym/games/mastermind/__init__.py +6 -0
- chuk_puzzles_gym/games/mastermind/config.py +25 -0
- chuk_puzzles_gym/games/mastermind/game.py +297 -0
- chuk_puzzles_gym/games/minesweeper/__init__.py +6 -0
- chuk_puzzles_gym/games/minesweeper/config.py +24 -0
- chuk_puzzles_gym/games/minesweeper/enums.py +12 -0
- chuk_puzzles_gym/games/minesweeper/game.py +432 -0
- chuk_puzzles_gym/games/nonogram/__init__.py +6 -0
- chuk_puzzles_gym/games/nonogram/config.py +23 -0
- chuk_puzzles_gym/games/nonogram/game.py +296 -0
- chuk_puzzles_gym/games/nurikabe/__init__.py +6 -0
- chuk_puzzles_gym/games/nurikabe/config.py +24 -0
- chuk_puzzles_gym/games/nurikabe/enums.py +14 -0
- chuk_puzzles_gym/games/nurikabe/game.py +586 -0
- chuk_puzzles_gym/games/scheduler/__init__.py +6 -0
- chuk_puzzles_gym/games/scheduler/config.py +25 -0
- chuk_puzzles_gym/games/scheduler/constants.py +15 -0
- chuk_puzzles_gym/games/scheduler/enums.py +10 -0
- chuk_puzzles_gym/games/scheduler/game.py +431 -0
- chuk_puzzles_gym/games/scheduler/models.py +14 -0
- chuk_puzzles_gym/games/shikaku/__init__.py +6 -0
- chuk_puzzles_gym/games/shikaku/config.py +24 -0
- chuk_puzzles_gym/games/shikaku/game.py +419 -0
- chuk_puzzles_gym/games/slitherlink/__init__.py +6 -0
- chuk_puzzles_gym/games/slitherlink/config.py +23 -0
- chuk_puzzles_gym/games/slitherlink/game.py +386 -0
- chuk_puzzles_gym/games/sokoban/__init__.py +6 -0
- chuk_puzzles_gym/games/sokoban/config.py +24 -0
- chuk_puzzles_gym/games/sokoban/game.py +671 -0
- chuk_puzzles_gym/games/star_battle/__init__.py +6 -0
- chuk_puzzles_gym/games/star_battle/config.py +24 -0
- chuk_puzzles_gym/games/star_battle/game.py +390 -0
- chuk_puzzles_gym/games/sudoku/__init__.py +7 -0
- chuk_puzzles_gym/games/sudoku/commands.py +96 -0
- chuk_puzzles_gym/games/sudoku/config.py +22 -0
- chuk_puzzles_gym/games/sudoku/game.py +328 -0
- chuk_puzzles_gym/games/tents/__init__.py +6 -0
- chuk_puzzles_gym/games/tents/config.py +24 -0
- chuk_puzzles_gym/games/tents/game.py +416 -0
- chuk_puzzles_gym/gym_env.py +465 -0
- chuk_puzzles_gym/models/__init__.py +47 -0
- chuk_puzzles_gym/models/base.py +30 -0
- chuk_puzzles_gym/models/config.py +11 -0
- chuk_puzzles_gym/models/enums.py +104 -0
- chuk_puzzles_gym/models/evaluation.py +487 -0
- chuk_puzzles_gym/models/games.py +12 -0
- chuk_puzzles_gym/server.py +1171 -0
- chuk_puzzles_gym/trace/__init__.py +10 -0
- chuk_puzzles_gym/trace/generator.py +726 -0
- chuk_puzzles_gym/utils/__init__.py +4 -0
- chuk_puzzles_gym-0.9.dist-info/METADATA +1471 -0
- chuk_puzzles_gym-0.9.dist-info/RECORD +112 -0
- chuk_puzzles_gym-0.9.dist-info/WHEEL +5 -0
- chuk_puzzles_gym-0.9.dist-info/entry_points.txt +4 -0
- 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,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)
|