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,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,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)
|