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,403 @@
|
|
|
1
|
+
"""Hidato (Number Snake) 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 HidatoConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HidatoGame(PuzzleGame):
|
|
11
|
+
"""Hidato (Number Snake) puzzle game.
|
|
12
|
+
|
|
13
|
+
Fill the grid with consecutive numbers (1 to N) such that each number
|
|
14
|
+
is adjacent (horizontally, vertically, or diagonally) to the next number.
|
|
15
|
+
Creates a continuous path through all cells.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
19
|
+
"""Initialize a new Hidato game.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
difficulty: Game difficulty level (easy=5x5, medium=7x7, hard=9x9)
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
25
|
+
|
|
26
|
+
# Use pydantic config based on difficulty
|
|
27
|
+
self.config = HidatoConfig.from_difficulty(self.difficulty)
|
|
28
|
+
self.size = self.config.size
|
|
29
|
+
|
|
30
|
+
# Grid: 0 = empty, 1-N = numbers
|
|
31
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
32
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
33
|
+
self.initial_grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
34
|
+
|
|
35
|
+
# Total numbers to place
|
|
36
|
+
self.total_numbers = self.size * self.size
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
"""The display name of this puzzle type."""
|
|
41
|
+
return "Hidato"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def description(self) -> str:
|
|
45
|
+
"""A one-line description of this puzzle type."""
|
|
46
|
+
return "Number snake puzzle - connect consecutive numbers via adjacent cells"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def constraint_types(self) -> list[str]:
|
|
50
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
51
|
+
return ["sequential_adjacency", "hamiltonian_path", "all_different", "connectivity"]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def business_analogies(self) -> list[str]:
|
|
55
|
+
"""Business problems this puzzle models."""
|
|
56
|
+
return ["route_optimization", "sequential_process_flow", "path_finding", "order_fulfillment"]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
60
|
+
"""Complexity profile of this puzzle."""
|
|
61
|
+
return {"reasoning_type": "deductive", "search_space": "large", "constraint_density": "dense"}
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def optimal_steps(self) -> int | None:
|
|
65
|
+
"""Minimum steps = empty cells to fill."""
|
|
66
|
+
return sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
70
|
+
"""Difficulty characteristics for Hidato."""
|
|
71
|
+
from ...models import DifficultyLevel
|
|
72
|
+
|
|
73
|
+
empty = self.optimal_steps or 0
|
|
74
|
+
total = self.size * self.size
|
|
75
|
+
logic_depth = {
|
|
76
|
+
DifficultyLevel.EASY.value: 2,
|
|
77
|
+
DifficultyLevel.MEDIUM.value: 4,
|
|
78
|
+
DifficultyLevel.HARD.value: 5,
|
|
79
|
+
}.get(self.difficulty.value, 3)
|
|
80
|
+
branching = 4.0 + (empty / total) * 4 # Up to 8 neighbors
|
|
81
|
+
density = 1.0 - (empty / total) if total > 0 else 0.5
|
|
82
|
+
return DifficultyProfile(
|
|
83
|
+
logic_depth=logic_depth,
|
|
84
|
+
branching_factor=round(branching, 1),
|
|
85
|
+
state_observability=1.0,
|
|
86
|
+
constraint_density=round(density, 2),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _get_neighbors(self, row: int, col: int) -> list[tuple[int, int]]:
|
|
90
|
+
"""Get all adjacent cells (including diagonals).
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
row: Row index
|
|
94
|
+
col: Column index
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of (row, col) tuples for valid neighbors
|
|
98
|
+
"""
|
|
99
|
+
neighbors = []
|
|
100
|
+
for dr in [-1, 0, 1]:
|
|
101
|
+
for dc in [-1, 0, 1]:
|
|
102
|
+
if dr == 0 and dc == 0:
|
|
103
|
+
continue
|
|
104
|
+
nr, nc = row + dr, col + dc
|
|
105
|
+
if 0 <= nr < self.size and 0 <= nc < self.size:
|
|
106
|
+
neighbors.append((nr, nc))
|
|
107
|
+
return neighbors
|
|
108
|
+
|
|
109
|
+
def _generate_path(self) -> bool:
|
|
110
|
+
"""Generate a valid Hamiltonian path through the grid.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
True if path generation succeeded
|
|
114
|
+
"""
|
|
115
|
+
# Use a greedy approach with random walks for efficiency
|
|
116
|
+
# Start from a random position
|
|
117
|
+
row = self._rng.randint(0, self.size - 1)
|
|
118
|
+
col = self._rng.randint(0, self.size - 1)
|
|
119
|
+
|
|
120
|
+
visited = set()
|
|
121
|
+
path = []
|
|
122
|
+
|
|
123
|
+
# Greedy walk through the grid
|
|
124
|
+
for _ in range(self.total_numbers):
|
|
125
|
+
visited.add((row, col))
|
|
126
|
+
path.append((row, col))
|
|
127
|
+
|
|
128
|
+
if len(path) == self.total_numbers:
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
# Find unvisited neighbors
|
|
132
|
+
neighbors = self._get_neighbors(row, col)
|
|
133
|
+
unvisited = [(r, c) for r, c in neighbors if (r, c) not in visited]
|
|
134
|
+
|
|
135
|
+
if not unvisited:
|
|
136
|
+
# Dead end - this attempt failed
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
# Prefer neighbors with more unvisited neighbors (greedy heuristic)
|
|
140
|
+
def count_unvisited_neighbors(pos: tuple[int, int]) -> int:
|
|
141
|
+
r, c = pos
|
|
142
|
+
neighs = self._get_neighbors(r, c)
|
|
143
|
+
return sum(1 for nr, nc in neighs if (nr, nc) not in visited)
|
|
144
|
+
|
|
145
|
+
# Sort by number of unvisited neighbors (descending)
|
|
146
|
+
unvisited.sort(key=count_unvisited_neighbors, reverse=True)
|
|
147
|
+
|
|
148
|
+
# Pick one of the best choices (add some randomness)
|
|
149
|
+
if len(unvisited) > 1 and self._rng.random() < 0.3:
|
|
150
|
+
# 30% chance to pick second-best to add variety
|
|
151
|
+
row, col = unvisited[1] if len(unvisited) > 1 else unvisited[0]
|
|
152
|
+
else:
|
|
153
|
+
row, col = unvisited[0]
|
|
154
|
+
|
|
155
|
+
# Fill solution grid with the path
|
|
156
|
+
for i, (r, c) in enumerate(path, start=1):
|
|
157
|
+
self.solution[r][c] = i
|
|
158
|
+
|
|
159
|
+
return len(path) == self.total_numbers
|
|
160
|
+
|
|
161
|
+
def _generate_serpentine_path(self) -> None:
|
|
162
|
+
"""Generate a serpentine (snake) path as a fallback.
|
|
163
|
+
|
|
164
|
+
This always succeeds and creates a readable pattern.
|
|
165
|
+
"""
|
|
166
|
+
num = 1
|
|
167
|
+
for row in range(self.size):
|
|
168
|
+
if row % 2 == 0:
|
|
169
|
+
# Left to right
|
|
170
|
+
for col in range(self.size):
|
|
171
|
+
self.solution[row][col] = num
|
|
172
|
+
num += 1
|
|
173
|
+
else:
|
|
174
|
+
# Right to left
|
|
175
|
+
for col in range(self.size - 1, -1, -1):
|
|
176
|
+
self.solution[row][col] = num
|
|
177
|
+
num += 1
|
|
178
|
+
|
|
179
|
+
async def generate_puzzle(self) -> None:
|
|
180
|
+
"""Generate a new Hidato puzzle."""
|
|
181
|
+
# Try greedy generation a few times, then fallback to serpentine
|
|
182
|
+
max_attempts = 50
|
|
183
|
+
success = False
|
|
184
|
+
for _ in range(max_attempts):
|
|
185
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
186
|
+
if self._generate_path():
|
|
187
|
+
success = True
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
# If no success, use serpentine pattern (always works)
|
|
191
|
+
if not success:
|
|
192
|
+
self._generate_serpentine_path()
|
|
193
|
+
|
|
194
|
+
# Create the puzzle by revealing some numbers
|
|
195
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
196
|
+
|
|
197
|
+
# Always reveal first and last numbers
|
|
198
|
+
for r in range(self.size):
|
|
199
|
+
for c in range(self.size):
|
|
200
|
+
if self.solution[r][c] == 1:
|
|
201
|
+
self.grid[r][c] = 1
|
|
202
|
+
elif self.solution[r][c] == self.total_numbers:
|
|
203
|
+
self.grid[r][c] = self.total_numbers
|
|
204
|
+
|
|
205
|
+
# Reveal additional clue numbers based on difficulty
|
|
206
|
+
num_clues = self.config.num_clues
|
|
207
|
+
all_positions = [(r, c) for r in range(self.size) for c in range(self.size)]
|
|
208
|
+
self._rng.shuffle(all_positions)
|
|
209
|
+
|
|
210
|
+
revealed = 2 # Already revealed first and last
|
|
211
|
+
for r, c in all_positions:
|
|
212
|
+
if revealed >= num_clues:
|
|
213
|
+
break
|
|
214
|
+
if self.grid[r][c] == 0: # Not already revealed
|
|
215
|
+
self.grid[r][c] = self.solution[r][c]
|
|
216
|
+
revealed += 1
|
|
217
|
+
|
|
218
|
+
# Store initial state
|
|
219
|
+
self.initial_grid = [row[:] for row in self.grid]
|
|
220
|
+
self.moves_made = 0
|
|
221
|
+
self.game_started = True
|
|
222
|
+
|
|
223
|
+
async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
|
|
224
|
+
"""Place a number on the grid.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
row: Row index (1-indexed, user-facing)
|
|
228
|
+
col: Column index (1-indexed, user-facing)
|
|
229
|
+
num: Number to place (1 to total_numbers, or 0 to clear)
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
MoveResult with success status and message
|
|
233
|
+
"""
|
|
234
|
+
# Convert to 0-indexed
|
|
235
|
+
row -= 1
|
|
236
|
+
col -= 1
|
|
237
|
+
|
|
238
|
+
# Validate coordinates
|
|
239
|
+
if not (0 <= row < self.size and 0 <= col < self.size):
|
|
240
|
+
return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
|
|
241
|
+
|
|
242
|
+
# Check if this cell is part of the initial puzzle
|
|
243
|
+
if self.initial_grid[row][col] != 0:
|
|
244
|
+
return MoveResult(success=False, message="Cannot modify initial clue cells.")
|
|
245
|
+
|
|
246
|
+
# Clear the cell
|
|
247
|
+
if num == 0:
|
|
248
|
+
self.grid[row][col] = 0
|
|
249
|
+
return MoveResult(success=True, message="Cell cleared.", state_changed=True)
|
|
250
|
+
|
|
251
|
+
# Validate number range
|
|
252
|
+
if not (1 <= num <= self.total_numbers):
|
|
253
|
+
return MoveResult(success=False, message=f"Invalid number. Use 1-{self.total_numbers} or 0 to clear.")
|
|
254
|
+
|
|
255
|
+
# Check if number is already used elsewhere
|
|
256
|
+
for r in range(self.size):
|
|
257
|
+
for c in range(self.size):
|
|
258
|
+
if (r, c) != (row, col) and self.grid[r][c] == num:
|
|
259
|
+
return MoveResult(success=False, message=f"Number {num} is already used at ({r + 1},{c + 1}).")
|
|
260
|
+
|
|
261
|
+
# Place the number
|
|
262
|
+
self.grid[row][col] = num
|
|
263
|
+
self.moves_made += 1
|
|
264
|
+
return MoveResult(success=True, message="Number placed successfully!", state_changed=True)
|
|
265
|
+
|
|
266
|
+
def is_complete(self) -> bool:
|
|
267
|
+
"""Check if the puzzle is complete and correct."""
|
|
268
|
+
# Check all cells are filled
|
|
269
|
+
for row in range(self.size):
|
|
270
|
+
for col in range(self.size):
|
|
271
|
+
if self.grid[row][col] == 0:
|
|
272
|
+
return False
|
|
273
|
+
if self.grid[row][col] != self.solution[row][col]:
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
# Check adjacency (each number n must be adjacent to n+1)
|
|
277
|
+
for num in range(1, self.total_numbers):
|
|
278
|
+
# Find position of num
|
|
279
|
+
pos_num = None
|
|
280
|
+
pos_next = None
|
|
281
|
+
|
|
282
|
+
for r in range(self.size):
|
|
283
|
+
for c in range(self.size):
|
|
284
|
+
if self.grid[r][c] == num:
|
|
285
|
+
pos_num = (r, c)
|
|
286
|
+
if self.grid[r][c] == num + 1:
|
|
287
|
+
pos_next = (r, c)
|
|
288
|
+
|
|
289
|
+
if pos_num is None or pos_next is None:
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
# Check if they're adjacent
|
|
293
|
+
neighbors = self._get_neighbors(pos_num[0], pos_num[1])
|
|
294
|
+
if pos_next not in neighbors:
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
300
|
+
"""Get a hint for the next move.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
304
|
+
"""
|
|
305
|
+
# Find an empty cell
|
|
306
|
+
empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0]
|
|
307
|
+
if not empty_cells:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
# Prefer cells that have known neighbors
|
|
311
|
+
for r, c in empty_cells:
|
|
312
|
+
target_num = self.solution[r][c]
|
|
313
|
+
neighbors = self._get_neighbors(r, c)
|
|
314
|
+
|
|
315
|
+
# Check if this cell's number has placed neighbors
|
|
316
|
+
for nr, nc in neighbors:
|
|
317
|
+
if self.grid[nr][nc] in [target_num - 1, target_num + 1]:
|
|
318
|
+
hint_data = (r + 1, c + 1, target_num)
|
|
319
|
+
hint_message = f"Try placing {target_num} at row {r + 1}, column {c + 1}"
|
|
320
|
+
return hint_data, hint_message
|
|
321
|
+
|
|
322
|
+
# Otherwise just give any empty cell
|
|
323
|
+
r, c = self._rng.choice(empty_cells)
|
|
324
|
+
target_num = self.solution[r][c]
|
|
325
|
+
hint_data = (r + 1, c + 1, target_num)
|
|
326
|
+
hint_message = f"Try placing {target_num} at row {r + 1}, column {c + 1}"
|
|
327
|
+
return hint_data, hint_message
|
|
328
|
+
|
|
329
|
+
def render_grid(self) -> str:
|
|
330
|
+
"""Render the current puzzle state as ASCII art.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
String representation of the puzzle grid
|
|
334
|
+
"""
|
|
335
|
+
lines = []
|
|
336
|
+
|
|
337
|
+
# Calculate cell width based on total numbers
|
|
338
|
+
cell_width = len(str(self.total_numbers)) + 1
|
|
339
|
+
|
|
340
|
+
# Header
|
|
341
|
+
header = " |"
|
|
342
|
+
for c in range(self.size):
|
|
343
|
+
header += f" {c + 1:^{cell_width}}"
|
|
344
|
+
lines.append(header)
|
|
345
|
+
lines.append(" +" + "-" * (cell_width + 1) * self.size)
|
|
346
|
+
|
|
347
|
+
# Grid rows
|
|
348
|
+
for r in range(self.size):
|
|
349
|
+
row_str = f"{r + 1:2}|"
|
|
350
|
+
for c in range(self.size):
|
|
351
|
+
cell = self.grid[r][c]
|
|
352
|
+
if cell == 0:
|
|
353
|
+
row_str += f" {'.':{cell_width}}"
|
|
354
|
+
else:
|
|
355
|
+
# Mark initial clues differently
|
|
356
|
+
if self.initial_grid[r][c] != 0:
|
|
357
|
+
row_str += f" {cell:{cell_width}}"
|
|
358
|
+
else:
|
|
359
|
+
row_str += f" {cell:{cell_width}}"
|
|
360
|
+
lines.append(row_str)
|
|
361
|
+
|
|
362
|
+
lines.append("\nLegend: . = empty, numbers = placed/clues")
|
|
363
|
+
lines.append(f"Goal: Fill grid with numbers 1-{self.total_numbers}, each adjacent to the next")
|
|
364
|
+
|
|
365
|
+
return "\n".join(lines)
|
|
366
|
+
|
|
367
|
+
def get_rules(self) -> str:
|
|
368
|
+
"""Get the rules description for Hidato.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Multi-line string describing the puzzle rules
|
|
372
|
+
"""
|
|
373
|
+
return f"""HIDATO (NUMBER SNAKE) RULES:
|
|
374
|
+
- Fill the grid with consecutive numbers from 1 to {self.total_numbers}
|
|
375
|
+
- Each number must be adjacent (horizontally, vertically, or diagonally) to the next number
|
|
376
|
+
- Some numbers are given as clues
|
|
377
|
+
- Create one continuous path through all cells
|
|
378
|
+
- Each number appears exactly once"""
|
|
379
|
+
|
|
380
|
+
def get_commands(self) -> str:
|
|
381
|
+
"""Get the available commands for Hidato.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Multi-line string describing available commands
|
|
385
|
+
"""
|
|
386
|
+
return """HIDATO COMMANDS:
|
|
387
|
+
place <row> <col> <num> - Place a number (e.g., 'place 1 5 7')
|
|
388
|
+
clear <row> <col> - Clear a cell you've filled (same as 'place <row> <col> 0')
|
|
389
|
+
show - Display the current grid
|
|
390
|
+
hint - Get a hint for the next move
|
|
391
|
+
check - Check your progress
|
|
392
|
+
solve - Show the solution (ends game)
|
|
393
|
+
menu - Return to game selection
|
|
394
|
+
quit - Exit the server"""
|
|
395
|
+
|
|
396
|
+
def get_stats(self) -> str:
|
|
397
|
+
"""Get current game statistics.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
String with game stats
|
|
401
|
+
"""
|
|
402
|
+
filled = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] != 0)
|
|
403
|
+
return f"Moves made: {self.moves_made} | Filled: {filled}/{self.total_numbers} | Seed: {self.seed}"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Configuration for Hitori game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HitoriConfig(BaseModel):
|
|
9
|
+
"""Configuration for Hitori 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) -> "HitoriConfig":
|
|
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)
|