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,419 @@
|
|
|
1
|
+
"""Shikaku (Rectangles) 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 ShikakuGame(PuzzleGame):
|
|
10
|
+
"""Shikaku (Rectangles) puzzle game.
|
|
11
|
+
|
|
12
|
+
Divide the grid into rectangles such that:
|
|
13
|
+
- Each rectangle contains exactly one number
|
|
14
|
+
- The number indicates the area of that rectangle
|
|
15
|
+
- All cells must be covered by rectangles
|
|
16
|
+
- Rectangles cannot overlap
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
20
|
+
"""Initialize a new Shikaku game.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
difficulty: Game difficulty level (easy, medium, hard)
|
|
24
|
+
"""
|
|
25
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
26
|
+
|
|
27
|
+
from ...models import DifficultyLevel
|
|
28
|
+
|
|
29
|
+
# Set grid size based on difficulty
|
|
30
|
+
self.size = {
|
|
31
|
+
DifficultyLevel.EASY.value: 6,
|
|
32
|
+
DifficultyLevel.MEDIUM.value: 8,
|
|
33
|
+
DifficultyLevel.HARD.value: 10,
|
|
34
|
+
}.get(self.difficulty.value, 6)
|
|
35
|
+
|
|
36
|
+
# Grid stores the clue numbers (0 = no clue)
|
|
37
|
+
self.grid: list[list[int]] = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
38
|
+
|
|
39
|
+
# Solution stores rectangle IDs (each rectangle has a unique ID)
|
|
40
|
+
self.solution: list[list[int]] = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
41
|
+
|
|
42
|
+
# Player's rectangles (rectangle_id -> list of (row, col) cells)
|
|
43
|
+
self.rectangles: dict[int, list[tuple[int, int]]] = {}
|
|
44
|
+
self.next_rect_id = 1
|
|
45
|
+
|
|
46
|
+
# Store clue positions for validation
|
|
47
|
+
self.clues: dict[tuple[int, int], int] = {}
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def name(self) -> str:
|
|
51
|
+
"""The display name of this puzzle type."""
|
|
52
|
+
return "Shikaku"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def description(self) -> str:
|
|
56
|
+
"""A one-line description of this puzzle type."""
|
|
57
|
+
return "Divide grid into rectangles matching the given areas"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def constraint_types(self) -> list[str]:
|
|
61
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
62
|
+
return ["partition", "area_constraints", "rectangle_tiling", "non_overlapping", "coverage"]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def business_analogies(self) -> list[str]:
|
|
66
|
+
"""Business problems this puzzle models."""
|
|
67
|
+
return ["space_allocation", "territory_division", "resource_partitioning", "layout_optimization"]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
71
|
+
"""Complexity profile of this puzzle."""
|
|
72
|
+
return {"reasoning_type": "deductive", "search_space": "large", "constraint_density": "moderate"}
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def optimal_steps(self) -> int | None:
|
|
76
|
+
"""Minimum steps = rectangles to create."""
|
|
77
|
+
if not hasattr(self, "solution") or not self.solution:
|
|
78
|
+
return None
|
|
79
|
+
return max(max(row) for row in self.solution) if self.solution else None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
83
|
+
"""Difficulty characteristics for Shikaku."""
|
|
84
|
+
from ...models import DifficultyLevel
|
|
85
|
+
|
|
86
|
+
logic_depth = {
|
|
87
|
+
DifficultyLevel.EASY.value: 2,
|
|
88
|
+
DifficultyLevel.MEDIUM.value: 4,
|
|
89
|
+
DifficultyLevel.HARD.value: 5,
|
|
90
|
+
}.get(self.difficulty.value, 3)
|
|
91
|
+
return DifficultyProfile(
|
|
92
|
+
logic_depth=logic_depth,
|
|
93
|
+
branching_factor=5.0, # Many rectangle possibilities
|
|
94
|
+
state_observability=1.0,
|
|
95
|
+
constraint_density=0.5,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
async def generate_puzzle(self) -> None:
|
|
99
|
+
"""Generate a new Shikaku puzzle with retry logic."""
|
|
100
|
+
max_attempts = 50
|
|
101
|
+
|
|
102
|
+
for _attempt in range(max_attempts):
|
|
103
|
+
# Reset state
|
|
104
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
105
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
106
|
+
self.clues = {}
|
|
107
|
+
|
|
108
|
+
# Step 1: Generate solution by creating rectangles
|
|
109
|
+
if self._generate_rectangles():
|
|
110
|
+
# Step 2: Validate puzzle
|
|
111
|
+
if self._validate_puzzle():
|
|
112
|
+
self.game_started = True
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Fallback: use last attempt
|
|
116
|
+
self.game_started = True
|
|
117
|
+
|
|
118
|
+
def _generate_rectangles(self) -> bool:
|
|
119
|
+
"""Generate rectangles to fill the grid."""
|
|
120
|
+
rect_id = 1
|
|
121
|
+
remaining_cells = {(r, c) for r in range(self.size) for c in range(self.size)}
|
|
122
|
+
|
|
123
|
+
# Use strategic starting points (grid positions) for better coverage
|
|
124
|
+
start_positions = []
|
|
125
|
+
step = max(2, self.size // 3)
|
|
126
|
+
for r in range(0, self.size, step):
|
|
127
|
+
for c in range(0, self.size, step):
|
|
128
|
+
start_positions.append((r, c))
|
|
129
|
+
self._rng.shuffle(start_positions)
|
|
130
|
+
|
|
131
|
+
attempts = 0
|
|
132
|
+
max_rect_attempts = 100
|
|
133
|
+
|
|
134
|
+
while remaining_cells and attempts < max_rect_attempts:
|
|
135
|
+
attempts += 1
|
|
136
|
+
|
|
137
|
+
# Try strategic positions first, then random
|
|
138
|
+
if start_positions:
|
|
139
|
+
# Filter start positions to only uncovered cells
|
|
140
|
+
valid_starts = [pos for pos in start_positions if pos in remaining_cells]
|
|
141
|
+
if valid_starts:
|
|
142
|
+
r, c = self._rng.choice(valid_starts)
|
|
143
|
+
else:
|
|
144
|
+
r, c = self._rng.choice(list(remaining_cells))
|
|
145
|
+
else:
|
|
146
|
+
r, c = self._rng.choice(list(remaining_cells))
|
|
147
|
+
|
|
148
|
+
# Determine maximum rectangle size
|
|
149
|
+
max_width = 1
|
|
150
|
+
while c + max_width < self.size and (r, c + max_width) in remaining_cells:
|
|
151
|
+
max_width += 1
|
|
152
|
+
|
|
153
|
+
max_height = 1
|
|
154
|
+
while r + max_height < self.size and (r + max_height, c) in remaining_cells:
|
|
155
|
+
max_height += 1
|
|
156
|
+
|
|
157
|
+
# Choose dimensions (prefer reasonable sizes)
|
|
158
|
+
max_dim = 4
|
|
159
|
+
width = self._rng.randint(1, min(max_width, max_dim))
|
|
160
|
+
height = self._rng.randint(1, min(max_height, max_dim))
|
|
161
|
+
|
|
162
|
+
# Ensure the rectangle fits
|
|
163
|
+
valid = True
|
|
164
|
+
rect_cells = []
|
|
165
|
+
for dr in range(height):
|
|
166
|
+
for dc in range(width):
|
|
167
|
+
nr, nc = r + dr, c + dc
|
|
168
|
+
if nr >= self.size or nc >= self.size or (nr, nc) not in remaining_cells:
|
|
169
|
+
valid = False
|
|
170
|
+
break
|
|
171
|
+
rect_cells.append((nr, nc))
|
|
172
|
+
if not valid:
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
if valid and rect_cells:
|
|
176
|
+
# Mark these cells with the rectangle ID
|
|
177
|
+
for nr, nc in rect_cells:
|
|
178
|
+
self.solution[nr][nc] = rect_id
|
|
179
|
+
remaining_cells.remove((nr, nc))
|
|
180
|
+
|
|
181
|
+
# Place the clue number in a random cell within the rectangle
|
|
182
|
+
clue_r, clue_c = self._rng.choice(rect_cells)
|
|
183
|
+
area = width * height
|
|
184
|
+
self.grid[clue_r][clue_c] = area
|
|
185
|
+
self.clues[(clue_r, clue_c)] = area
|
|
186
|
+
|
|
187
|
+
rect_id += 1
|
|
188
|
+
else:
|
|
189
|
+
# If we can't create a valid rectangle, just use a single cell
|
|
190
|
+
self.solution[r][c] = rect_id
|
|
191
|
+
self.grid[r][c] = 1
|
|
192
|
+
self.clues[(r, c)] = 1
|
|
193
|
+
remaining_cells.remove((r, c))
|
|
194
|
+
rect_id += 1
|
|
195
|
+
|
|
196
|
+
return len(remaining_cells) == 0
|
|
197
|
+
|
|
198
|
+
def _validate_puzzle(self) -> bool:
|
|
199
|
+
"""Validate that the generated puzzle is valid."""
|
|
200
|
+
# Check all cells are covered
|
|
201
|
+
for r in range(self.size):
|
|
202
|
+
for c in range(self.size):
|
|
203
|
+
if self.solution[r][c] == 0:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
# Check each clue matches its rectangle area
|
|
207
|
+
for (clue_r, clue_c), area in self.clues.items():
|
|
208
|
+
rect_id = self.solution[clue_r][clue_c]
|
|
209
|
+
rect_cells = sum(1 for r in range(self.size) for c in range(self.size) if self.solution[r][c] == rect_id)
|
|
210
|
+
|
|
211
|
+
if rect_cells != area:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
# Check no rectangle has multiple clues
|
|
215
|
+
rect_clue_count: dict[int, int] = {}
|
|
216
|
+
for (r, c), _area in self.clues.items():
|
|
217
|
+
rect_id = self.solution[r][c]
|
|
218
|
+
rect_clue_count[rect_id] = rect_clue_count.get(rect_id, 0) + 1
|
|
219
|
+
|
|
220
|
+
for count in rect_clue_count.values():
|
|
221
|
+
if count != 1:
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
async def validate_move(self, *args: Any, **kwargs: Any) -> MoveResult:
|
|
227
|
+
"""Validate a rectangle placement move.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
args[0]: Top-left row (1-indexed)
|
|
231
|
+
args[1]: Top-left column (1-indexed)
|
|
232
|
+
args[2]: Bottom-right row (1-indexed)
|
|
233
|
+
args[3]: Bottom-right column (1-indexed)
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
MoveResult containing success status and message
|
|
237
|
+
"""
|
|
238
|
+
if len(args) < 4:
|
|
239
|
+
return MoveResult(success=False, message="Usage: place <top_row> <top_col> <bottom_row> <bottom_col>")
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
r1, c1, r2, c2 = int(args[0]) - 1, int(args[1]) - 1, int(args[2]) - 1, int(args[3]) - 1
|
|
243
|
+
except (ValueError, IndexError):
|
|
244
|
+
return MoveResult(success=False, message="Invalid coordinates")
|
|
245
|
+
|
|
246
|
+
# Normalize coordinates
|
|
247
|
+
if r1 > r2:
|
|
248
|
+
r1, r2 = r2, r1
|
|
249
|
+
if c1 > c2:
|
|
250
|
+
c1, c2 = c2, c1
|
|
251
|
+
|
|
252
|
+
# Validate coordinates
|
|
253
|
+
if not (0 <= r1 <= r2 < self.size and 0 <= c1 <= c2 < self.size):
|
|
254
|
+
return MoveResult(success=False, message="Coordinates out of range")
|
|
255
|
+
|
|
256
|
+
# Check if cells are already covered
|
|
257
|
+
for r in range(r1, r2 + 1):
|
|
258
|
+
for c in range(c1, c2 + 1):
|
|
259
|
+
for rect_id, cells in self.rectangles.items():
|
|
260
|
+
if (r, c) in cells:
|
|
261
|
+
return MoveResult(
|
|
262
|
+
success=False,
|
|
263
|
+
message=f"Cell ({r + 1},{c + 1}) is already covered by rectangle {rect_id}",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Find if rectangle contains exactly one clue
|
|
267
|
+
clue_count = 0
|
|
268
|
+
clue_value = 0
|
|
269
|
+
for r in range(r1, r2 + 1):
|
|
270
|
+
for c in range(c1, c2 + 1):
|
|
271
|
+
if (r, c) in self.clues:
|
|
272
|
+
clue_count += 1
|
|
273
|
+
clue_value = self.clues[(r, c)]
|
|
274
|
+
|
|
275
|
+
if clue_count == 0:
|
|
276
|
+
return MoveResult(success=False, message="Rectangle must contain exactly one clue number")
|
|
277
|
+
if clue_count > 1:
|
|
278
|
+
return MoveResult(success=False, message="Rectangle contains multiple clue numbers")
|
|
279
|
+
|
|
280
|
+
# Check if area matches the clue
|
|
281
|
+
width = c2 - c1 + 1
|
|
282
|
+
height = r2 - r1 + 1
|
|
283
|
+
area = width * height
|
|
284
|
+
|
|
285
|
+
if area != clue_value:
|
|
286
|
+
return MoveResult(success=False, message=f"Rectangle area ({area}) doesn't match clue ({clue_value})")
|
|
287
|
+
|
|
288
|
+
# Place the rectangle
|
|
289
|
+
cells = [(r, c) for r in range(r1, r2 + 1) for c in range(c1, c2 + 1)]
|
|
290
|
+
self.rectangles[self.next_rect_id] = cells
|
|
291
|
+
self.next_rect_id += 1
|
|
292
|
+
self.moves_made += 1
|
|
293
|
+
|
|
294
|
+
return MoveResult(success=True, message=f"Rectangle placed (area {area})")
|
|
295
|
+
|
|
296
|
+
def is_complete(self) -> bool:
|
|
297
|
+
"""Check if the puzzle is completely and correctly solved."""
|
|
298
|
+
# Check that all cells are covered
|
|
299
|
+
covered_cells = set()
|
|
300
|
+
for cells in self.rectangles.values():
|
|
301
|
+
for cell in cells:
|
|
302
|
+
if cell in covered_cells:
|
|
303
|
+
return False # Overlapping rectangles
|
|
304
|
+
covered_cells.add(cell)
|
|
305
|
+
|
|
306
|
+
# Check if all cells are covered
|
|
307
|
+
total_cells = self.size * self.size
|
|
308
|
+
if len(covered_cells) != total_cells:
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
# Check that each rectangle contains exactly one clue with matching area
|
|
312
|
+
for cells in self.rectangles.values():
|
|
313
|
+
clue_count = 0
|
|
314
|
+
clue_value = 0
|
|
315
|
+
for r, c in cells:
|
|
316
|
+
if (r, c) in self.clues:
|
|
317
|
+
clue_count += 1
|
|
318
|
+
clue_value = self.clues[(r, c)]
|
|
319
|
+
|
|
320
|
+
if clue_count != 1:
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
if len(cells) != clue_value:
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
return True
|
|
327
|
+
|
|
328
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
329
|
+
"""Get a hint for the next move."""
|
|
330
|
+
# Find a rectangle from the solution that hasn't been placed yet
|
|
331
|
+
solution_rects: dict[int, list[tuple[int, int]]] = {}
|
|
332
|
+
for r in range(self.size):
|
|
333
|
+
for c in range(self.size):
|
|
334
|
+
rect_id = self.solution[r][c]
|
|
335
|
+
if rect_id not in solution_rects:
|
|
336
|
+
solution_rects[rect_id] = []
|
|
337
|
+
solution_rects[rect_id].append((r, c))
|
|
338
|
+
|
|
339
|
+
# Check which solution rectangles haven't been placed
|
|
340
|
+
for cells in solution_rects.values():
|
|
341
|
+
# Check if any cell in this rectangle is not yet covered
|
|
342
|
+
is_placed = False
|
|
343
|
+
for r, c in cells:
|
|
344
|
+
for placed_cells in self.rectangles.values():
|
|
345
|
+
if (r, c) in placed_cells:
|
|
346
|
+
is_placed = True
|
|
347
|
+
break
|
|
348
|
+
if is_placed:
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
if not is_placed:
|
|
352
|
+
# Found an unplaced rectangle
|
|
353
|
+
min_r = min(r for r, c in cells)
|
|
354
|
+
max_r = max(r for r, c in cells)
|
|
355
|
+
min_c = min(c for r, c in cells)
|
|
356
|
+
max_c = max(c for r, c in cells)
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
(min_r + 1, min_c + 1, max_r + 1, max_c + 1),
|
|
360
|
+
f"Try rectangle from ({min_r + 1},{min_c + 1}) to ({max_r + 1},{max_c + 1})",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
def render_grid(self) -> str:
|
|
366
|
+
"""Render the current puzzle state as ASCII art."""
|
|
367
|
+
lines = []
|
|
368
|
+
|
|
369
|
+
# Create a display grid showing rectangles
|
|
370
|
+
display = [[" . " for _ in range(self.size)] for _ in range(self.size)]
|
|
371
|
+
|
|
372
|
+
# Place clue numbers
|
|
373
|
+
for (r, c), value in self.clues.items():
|
|
374
|
+
display[r][c] = f" {value:2d}"
|
|
375
|
+
|
|
376
|
+
# Mark placed rectangles with letters
|
|
377
|
+
rect_ids = sorted(self.rectangles.keys())
|
|
378
|
+
for idx, rect_id in enumerate(rect_ids):
|
|
379
|
+
letter = chr(65 + (idx % 26)) # A, B, C, ...
|
|
380
|
+
for r, c in self.rectangles[rect_id]:
|
|
381
|
+
if (r, c) not in self.clues:
|
|
382
|
+
display[r][c] = f" {letter} "
|
|
383
|
+
|
|
384
|
+
# Header
|
|
385
|
+
header = " |"
|
|
386
|
+
for c in range(self.size):
|
|
387
|
+
header += f" {c + 1:2d}"
|
|
388
|
+
lines.append(header)
|
|
389
|
+
lines.append(" +" + "---" * self.size)
|
|
390
|
+
|
|
391
|
+
# Grid rows
|
|
392
|
+
for r in range(self.size):
|
|
393
|
+
row = f"{r + 1:2d}|"
|
|
394
|
+
for c in range(self.size):
|
|
395
|
+
row += display[r][c]
|
|
396
|
+
lines.append(row)
|
|
397
|
+
|
|
398
|
+
return "\n".join(lines)
|
|
399
|
+
|
|
400
|
+
def get_rules(self) -> str:
|
|
401
|
+
"""Get the rules description for this puzzle type."""
|
|
402
|
+
return """SHIKAKU (RECTANGLES) RULES:
|
|
403
|
+
- Divide the grid into rectangles
|
|
404
|
+
- Each rectangle must contain exactly one number
|
|
405
|
+
- The number shows the area (width × height) of that rectangle
|
|
406
|
+
- All cells must be covered by rectangles
|
|
407
|
+
- Rectangles cannot overlap
|
|
408
|
+
- Placed rectangles are marked with letters (A, B, C, ...)"""
|
|
409
|
+
|
|
410
|
+
def get_commands(self) -> str:
|
|
411
|
+
"""Get the available commands for this puzzle type."""
|
|
412
|
+
return """SHIKAKU COMMANDS:
|
|
413
|
+
place <r1> <c1> <r2> <c2> - Draw rectangle from top-left to bottom-right
|
|
414
|
+
Example: place 1 1 2 3 (creates 2×3 rectangle)
|
|
415
|
+
hint - Get a hint for the next move
|
|
416
|
+
check - Check if puzzle is complete
|
|
417
|
+
solve - Show the solution
|
|
418
|
+
menu - Return to main menu
|
|
419
|
+
quit - Exit the game"""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Configuration for Slitherlink game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SlitherlinkConfig(BaseModel):
|
|
9
|
+
"""Configuration for Slitherlink game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
size: int = Field(ge=5, le=10, description="Grid size (NxN)")
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "SlitherlinkConfig":
|
|
16
|
+
"""Create config from difficulty level."""
|
|
17
|
+
config_map = {
|
|
18
|
+
DifficultyLevel.EASY: {"size": 5},
|
|
19
|
+
DifficultyLevel.MEDIUM: {"size": 7},
|
|
20
|
+
DifficultyLevel.HARD: {"size": 10},
|
|
21
|
+
}
|
|
22
|
+
params = config_map[difficulty]
|
|
23
|
+
return cls(difficulty=difficulty, **params)
|