chuk-puzzles-gym 0.9__py3-none-any.whl → 0.10.1__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/eval.py +21 -0
- chuk_puzzles_gym/games/__init__.py +22 -0
- chuk_puzzles_gym/games/binary/game.py +2 -0
- chuk_puzzles_gym/games/bridges/game.py +2 -0
- chuk_puzzles_gym/games/cryptarithmetic/__init__.py +7 -0
- chuk_puzzles_gym/games/cryptarithmetic/commands.py +75 -0
- chuk_puzzles_gym/games/cryptarithmetic/config.py +23 -0
- chuk_puzzles_gym/games/cryptarithmetic/game.py +388 -0
- chuk_puzzles_gym/games/einstein/game.py +2 -0
- chuk_puzzles_gym/games/fillomino/game.py +2 -0
- chuk_puzzles_gym/games/futoshiki/game.py +2 -0
- chuk_puzzles_gym/games/graph_coloring/__init__.py +7 -0
- chuk_puzzles_gym/games/graph_coloring/commands.py +96 -0
- chuk_puzzles_gym/games/graph_coloring/config.py +24 -0
- chuk_puzzles_gym/games/graph_coloring/game.py +316 -0
- chuk_puzzles_gym/games/hidato/game.py +2 -0
- chuk_puzzles_gym/games/hitori/game.py +2 -0
- chuk_puzzles_gym/games/kakuro/game.py +2 -0
- chuk_puzzles_gym/games/kenken/game.py +2 -0
- chuk_puzzles_gym/games/killer_sudoku/game.py +2 -0
- chuk_puzzles_gym/games/knapsack/game.py +2 -0
- chuk_puzzles_gym/games/lights_out/game.py +2 -0
- chuk_puzzles_gym/games/logic_grid/game.py +2 -0
- chuk_puzzles_gym/games/mastermind/game.py +2 -0
- chuk_puzzles_gym/games/minesweeper/game.py +2 -0
- chuk_puzzles_gym/games/nonogram/game.py +2 -0
- chuk_puzzles_gym/games/nqueens/__init__.py +6 -0
- chuk_puzzles_gym/games/nqueens/config.py +23 -0
- chuk_puzzles_gym/games/nqueens/game.py +321 -0
- chuk_puzzles_gym/games/numberlink/__init__.py +6 -0
- chuk_puzzles_gym/games/numberlink/config.py +23 -0
- chuk_puzzles_gym/games/numberlink/game.py +344 -0
- chuk_puzzles_gym/games/nurikabe/game.py +2 -0
- chuk_puzzles_gym/games/rush_hour/__init__.py +8 -0
- chuk_puzzles_gym/games/rush_hour/commands.py +57 -0
- chuk_puzzles_gym/games/rush_hour/config.py +25 -0
- chuk_puzzles_gym/games/rush_hour/game.py +479 -0
- chuk_puzzles_gym/games/rush_hour/models.py +15 -0
- chuk_puzzles_gym/games/scheduler/game.py +2 -0
- chuk_puzzles_gym/games/shikaku/game.py +2 -0
- chuk_puzzles_gym/games/skyscrapers/__init__.py +6 -0
- chuk_puzzles_gym/games/skyscrapers/config.py +22 -0
- chuk_puzzles_gym/games/skyscrapers/game.py +282 -0
- chuk_puzzles_gym/games/slitherlink/game.py +2 -0
- chuk_puzzles_gym/games/sokoban/game.py +2 -0
- chuk_puzzles_gym/games/star_battle/game.py +2 -0
- chuk_puzzles_gym/games/sudoku/game.py +2 -0
- chuk_puzzles_gym/games/tents/game.py +2 -0
- chuk_puzzles_gym/server.py +18 -70
- chuk_puzzles_gym/trace/generator.py +87 -0
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/METADATA +60 -19
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/RECORD +55 -33
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/WHEEL +1 -1
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/entry_points.txt +0 -0
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""N-Queens puzzle game implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ...models import DifficultyLevel, DifficultyProfile, MoveResult
|
|
6
|
+
from .._base import PuzzleGame
|
|
7
|
+
from .config import NQueensConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NQueensGame(PuzzleGame):
|
|
11
|
+
"""N-Queens puzzle - place N queens on an NxN board with no conflicts.
|
|
12
|
+
|
|
13
|
+
Rules:
|
|
14
|
+
- Place exactly N queens on an NxN chessboard
|
|
15
|
+
- No two queens may share the same row, column, or diagonal
|
|
16
|
+
- Some queens may be pre-placed as hints
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
20
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
21
|
+
self.config = NQueensConfig.from_difficulty(self.difficulty)
|
|
22
|
+
self.size = self.config.size
|
|
23
|
+
self.grid: list[list[int]] = []
|
|
24
|
+
self.solution: list[list[int]] = []
|
|
25
|
+
self.initial_grid: list[list[int]] = []
|
|
26
|
+
self._queen_cols: list[int] = [] # Solution: queen_cols[row] = col
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
return "N-Queens"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def description(self) -> str:
|
|
34
|
+
return f"Place {self.size} queens on a {self.size}x{self.size} board with no conflicts"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def constraint_types(self) -> list[str]:
|
|
38
|
+
return ["placement", "attack_avoidance", "all_different", "diagonal_constraint"]
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def business_analogies(self) -> list[str]:
|
|
42
|
+
return ["non_conflicting_placement", "resource_allocation", "antenna_placement"]
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
46
|
+
return {
|
|
47
|
+
"reasoning_type": "deductive",
|
|
48
|
+
"search_space": "exponential",
|
|
49
|
+
"constraint_density": "moderate",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def complexity_metrics(self) -> dict[str, int | float]:
|
|
54
|
+
queens_placed = sum(1 for row in self.grid for cell in row if cell == 1)
|
|
55
|
+
return {
|
|
56
|
+
"variable_count": self.size,
|
|
57
|
+
"constraint_count": self.size * 3, # row + col + diag constraints
|
|
58
|
+
"domain_size": self.size,
|
|
59
|
+
"branching_factor": self.size / 2.0,
|
|
60
|
+
"empty_cells": self.size - queens_placed,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def difficulty_profile(self) -> DifficultyProfile:
|
|
65
|
+
profiles = {
|
|
66
|
+
DifficultyLevel.EASY: DifficultyProfile(
|
|
67
|
+
logic_depth=2, branching_factor=3.0, state_observability=1.0, constraint_density=0.5
|
|
68
|
+
),
|
|
69
|
+
DifficultyLevel.MEDIUM: DifficultyProfile(
|
|
70
|
+
logic_depth=4, branching_factor=4.0, state_observability=1.0, constraint_density=0.4
|
|
71
|
+
),
|
|
72
|
+
DifficultyLevel.HARD: DifficultyProfile(
|
|
73
|
+
logic_depth=6, branching_factor=6.0, state_observability=1.0, constraint_density=0.3
|
|
74
|
+
),
|
|
75
|
+
}
|
|
76
|
+
return profiles[self.difficulty]
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def optimal_steps(self) -> int | None:
|
|
80
|
+
"""Number of queens left to place."""
|
|
81
|
+
initial_queens = sum(1 for row in self.initial_grid for cell in row if cell == 1)
|
|
82
|
+
return self.size - initial_queens
|
|
83
|
+
|
|
84
|
+
def _solve_nqueens(self) -> list[int] | None:
|
|
85
|
+
"""Find a valid N-Queens solution using randomized backtracking.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of column positions for each row, or None if no solution.
|
|
89
|
+
"""
|
|
90
|
+
n = self.size
|
|
91
|
+
result: list[int] = [-1] * n
|
|
92
|
+
used_cols: set[int] = set()
|
|
93
|
+
diag1: set[int] = set() # row - col
|
|
94
|
+
diag2: set[int] = set() # row + col
|
|
95
|
+
|
|
96
|
+
# Create shuffled column order for each row (for randomization)
|
|
97
|
+
col_orders = []
|
|
98
|
+
for _ in range(n):
|
|
99
|
+
cols = list(range(n))
|
|
100
|
+
self._rng.shuffle(cols)
|
|
101
|
+
col_orders.append(cols)
|
|
102
|
+
|
|
103
|
+
def backtrack(row: int) -> bool:
|
|
104
|
+
if row == n:
|
|
105
|
+
return True
|
|
106
|
+
for col in col_orders[row]:
|
|
107
|
+
if col in used_cols:
|
|
108
|
+
continue
|
|
109
|
+
d1 = row - col
|
|
110
|
+
d2 = row + col
|
|
111
|
+
if d1 in diag1 or d2 in diag2:
|
|
112
|
+
continue
|
|
113
|
+
result[row] = col
|
|
114
|
+
used_cols.add(col)
|
|
115
|
+
diag1.add(d1)
|
|
116
|
+
diag2.add(d2)
|
|
117
|
+
if backtrack(row + 1):
|
|
118
|
+
return True
|
|
119
|
+
used_cols.discard(col)
|
|
120
|
+
diag1.discard(d1)
|
|
121
|
+
diag2.discard(d2)
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
if backtrack(0):
|
|
125
|
+
return result
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def _has_conflicts(self) -> bool:
|
|
129
|
+
"""Check if current grid has any queen conflicts."""
|
|
130
|
+
n = self.size
|
|
131
|
+
queens = []
|
|
132
|
+
for r in range(n):
|
|
133
|
+
for c in range(n):
|
|
134
|
+
if self.grid[r][c] == 1:
|
|
135
|
+
queens.append((r, c))
|
|
136
|
+
|
|
137
|
+
for i in range(len(queens)):
|
|
138
|
+
for j in range(i + 1, len(queens)):
|
|
139
|
+
r1, c1 = queens[i]
|
|
140
|
+
r2, c2 = queens[j]
|
|
141
|
+
if r1 == r2 or c1 == c2 or abs(r1 - r2) == abs(c1 - c2):
|
|
142
|
+
return True
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
async def generate_puzzle(self) -> None:
|
|
146
|
+
"""Generate an N-Queens puzzle."""
|
|
147
|
+
n = self.size
|
|
148
|
+
queen_cols = self._solve_nqueens()
|
|
149
|
+
if queen_cols is None:
|
|
150
|
+
raise RuntimeError(f"Failed to find N-Queens solution for N={n}")
|
|
151
|
+
|
|
152
|
+
self._queen_cols = queen_cols
|
|
153
|
+
|
|
154
|
+
# Build solution grid
|
|
155
|
+
self.solution = [[0] * n for _ in range(n)]
|
|
156
|
+
for r in range(n):
|
|
157
|
+
self.solution[r][queen_cols[r]] = 1
|
|
158
|
+
|
|
159
|
+
# Pre-place some queens as hints
|
|
160
|
+
self.grid = [[0] * n for _ in range(n)]
|
|
161
|
+
rows = list(range(n))
|
|
162
|
+
self._rng.shuffle(rows)
|
|
163
|
+
for r in rows[: self.config.pre_placed]:
|
|
164
|
+
self.grid[r][queen_cols[r]] = 1
|
|
165
|
+
|
|
166
|
+
self.initial_grid = [row[:] for row in self.grid]
|
|
167
|
+
self.game_started = True
|
|
168
|
+
|
|
169
|
+
async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
|
|
170
|
+
"""Validate placing or removing a queen.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
row: 1-indexed row
|
|
174
|
+
col: 1-indexed column
|
|
175
|
+
num: 1 to place queen, 0 to clear
|
|
176
|
+
"""
|
|
177
|
+
n = self.size
|
|
178
|
+
r, c = row - 1, col - 1
|
|
179
|
+
|
|
180
|
+
if not (0 <= r < n and 0 <= c < n):
|
|
181
|
+
self.record_move((row, col), False)
|
|
182
|
+
return MoveResult(success=False, message=f"Position ({row}, {col}) is out of bounds.")
|
|
183
|
+
|
|
184
|
+
if self.initial_grid[r][c] == 1 and num == 0:
|
|
185
|
+
self.record_move((row, col), False)
|
|
186
|
+
return MoveResult(success=False, message="Cannot remove a pre-placed queen.")
|
|
187
|
+
|
|
188
|
+
if num == 0:
|
|
189
|
+
if self.grid[r][c] == 0:
|
|
190
|
+
self.record_move((row, col), False)
|
|
191
|
+
return MoveResult(success=False, message="No queen at that position.")
|
|
192
|
+
self.grid[r][c] = 0
|
|
193
|
+
self.record_move((row, col), True)
|
|
194
|
+
return MoveResult(success=True, message=f"Removed queen from ({row}, {col}).", state_changed=True)
|
|
195
|
+
|
|
196
|
+
if num != 1:
|
|
197
|
+
self.record_move((row, col), False)
|
|
198
|
+
return MoveResult(success=False, message="Use 1 to place a queen or 0 to clear.")
|
|
199
|
+
|
|
200
|
+
if self.grid[r][c] == 1:
|
|
201
|
+
self.record_move((row, col), False)
|
|
202
|
+
return MoveResult(success=False, message="A queen is already at that position.")
|
|
203
|
+
|
|
204
|
+
# Check conflicts with existing queens
|
|
205
|
+
for rr in range(n):
|
|
206
|
+
for cc in range(n):
|
|
207
|
+
if self.grid[rr][cc] == 1:
|
|
208
|
+
if rr == r:
|
|
209
|
+
self.record_move((row, col), False)
|
|
210
|
+
return MoveResult(
|
|
211
|
+
success=False,
|
|
212
|
+
message=f"Conflicts with queen at ({rr + 1}, {cc + 1}) - same row.",
|
|
213
|
+
)
|
|
214
|
+
if cc == c:
|
|
215
|
+
self.record_move((row, col), False)
|
|
216
|
+
return MoveResult(
|
|
217
|
+
success=False,
|
|
218
|
+
message=f"Conflicts with queen at ({rr + 1}, {cc + 1}) - same column.",
|
|
219
|
+
)
|
|
220
|
+
if abs(rr - r) == abs(cc - c):
|
|
221
|
+
self.record_move((row, col), False)
|
|
222
|
+
return MoveResult(
|
|
223
|
+
success=False,
|
|
224
|
+
message=f"Conflicts with queen at ({rr + 1}, {cc + 1}) - same diagonal.",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
self.grid[r][c] = 1
|
|
228
|
+
self.record_move((row, col), True)
|
|
229
|
+
return MoveResult(success=True, message=f"Placed queen at ({row}, {col}).", state_changed=True)
|
|
230
|
+
|
|
231
|
+
def is_complete(self) -> bool:
|
|
232
|
+
"""Check if N queens are placed with no conflicts."""
|
|
233
|
+
n = self.size
|
|
234
|
+
queens = []
|
|
235
|
+
for r in range(n):
|
|
236
|
+
for c in range(n):
|
|
237
|
+
if self.grid[r][c] == 1:
|
|
238
|
+
queens.append((r, c))
|
|
239
|
+
|
|
240
|
+
if len(queens) != n:
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
# Verify no conflicts
|
|
244
|
+
cols = set()
|
|
245
|
+
diag1 = set()
|
|
246
|
+
diag2 = set()
|
|
247
|
+
for r, c in queens:
|
|
248
|
+
if c in cols or (r - c) in diag1 or (r + c) in diag2:
|
|
249
|
+
return False
|
|
250
|
+
cols.add(c)
|
|
251
|
+
diag1.add(r - c)
|
|
252
|
+
diag2.add(r + c)
|
|
253
|
+
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
257
|
+
"""Suggest the next queen to place from the solution."""
|
|
258
|
+
if not self.can_use_hint():
|
|
259
|
+
return None
|
|
260
|
+
n = self.size
|
|
261
|
+
for r in range(n):
|
|
262
|
+
c = self._queen_cols[r]
|
|
263
|
+
if self.grid[r][c] == 0:
|
|
264
|
+
return (
|
|
265
|
+
(r + 1, c + 1, 1),
|
|
266
|
+
f"Try placing a queen at row {r + 1}, column {c + 1}.",
|
|
267
|
+
)
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
def render_grid(self) -> str:
|
|
271
|
+
"""Render the chessboard with queens."""
|
|
272
|
+
n = self.size
|
|
273
|
+
lines = []
|
|
274
|
+
|
|
275
|
+
# Column headers
|
|
276
|
+
header = " " + " ".join(str(c + 1) for c in range(n))
|
|
277
|
+
lines.append(header)
|
|
278
|
+
lines.append(" " + "+" + "---" * n + "+")
|
|
279
|
+
|
|
280
|
+
for r in range(n):
|
|
281
|
+
cells = []
|
|
282
|
+
for c in range(n):
|
|
283
|
+
if self.grid[r][c] == 1:
|
|
284
|
+
if self.initial_grid[r][c] == 1:
|
|
285
|
+
cells.append("Q") # Pre-placed queen
|
|
286
|
+
else:
|
|
287
|
+
cells.append("Q") # Player-placed queen
|
|
288
|
+
else:
|
|
289
|
+
cells.append(".")
|
|
290
|
+
line = f" {r + 1} | " + " ".join(cells) + " |"
|
|
291
|
+
lines.append(line)
|
|
292
|
+
|
|
293
|
+
lines.append(" " + "+" + "---" * n + "+")
|
|
294
|
+
queens_placed = sum(1 for row in self.grid for cell in row if cell == 1)
|
|
295
|
+
lines.append(f"Queens: {queens_placed}/{n}")
|
|
296
|
+
|
|
297
|
+
return "\n".join(lines)
|
|
298
|
+
|
|
299
|
+
def get_stats(self) -> str:
|
|
300
|
+
"""Get current game statistics."""
|
|
301
|
+
placed = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 1)
|
|
302
|
+
return f"Moves: {self.moves_made} | Queens: {placed}/{self.size} | Board: {self.size}x{self.size} | Seed: {self.seed}"
|
|
303
|
+
|
|
304
|
+
def get_rules(self) -> str:
|
|
305
|
+
return (
|
|
306
|
+
f"N-QUEENS ({self.size}x{self.size})\n"
|
|
307
|
+
f"Place {self.size} queens on the board.\n"
|
|
308
|
+
"No two queens may share the same row, column, or diagonal.\n"
|
|
309
|
+
"Pre-placed queens (Q) cannot be removed."
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def get_commands(self) -> str:
|
|
313
|
+
return (
|
|
314
|
+
"Commands:\n"
|
|
315
|
+
" place <row> <col> 1 - Place a queen\n"
|
|
316
|
+
" clear <row> <col> - Remove a queen\n"
|
|
317
|
+
" hint - Get a hint\n"
|
|
318
|
+
" check - Check if solved\n"
|
|
319
|
+
" show - Show current state\n"
|
|
320
|
+
" menu - Return to menu"
|
|
321
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Configuration for Numberlink puzzle game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NumberlinkConfig(BaseModel):
|
|
9
|
+
"""Configuration for a Numberlink puzzle."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY)
|
|
12
|
+
size: int = Field(ge=4, le=12, description="Grid size (NxN)")
|
|
13
|
+
num_pairs: int = Field(ge=2, le=15, description="Number of endpoint pairs")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "NumberlinkConfig":
|
|
17
|
+
"""Create config from difficulty level."""
|
|
18
|
+
config_map = {
|
|
19
|
+
DifficultyLevel.EASY: {"size": 5, "num_pairs": 4},
|
|
20
|
+
DifficultyLevel.MEDIUM: {"size": 7, "num_pairs": 6},
|
|
21
|
+
DifficultyLevel.HARD: {"size": 9, "num_pairs": 9},
|
|
22
|
+
}
|
|
23
|
+
return cls(difficulty=difficulty, **config_map[difficulty])
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Numberlink (Flow) puzzle game implementation."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ...models import DifficultyLevel, DifficultyProfile, MoveResult
|
|
7
|
+
from .._base import PuzzleGame
|
|
8
|
+
from .config import NumberlinkConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NumberlinkGame(PuzzleGame):
|
|
12
|
+
"""Numberlink puzzle - connect numbered pairs with non-crossing paths.
|
|
13
|
+
|
|
14
|
+
Rules:
|
|
15
|
+
- The grid contains pairs of numbered endpoints
|
|
16
|
+
- Connect each pair with a continuous path
|
|
17
|
+
- Paths cannot cross or overlap
|
|
18
|
+
- Every cell must be part of exactly one path
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
22
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
23
|
+
self.config = NumberlinkConfig.from_difficulty(self.difficulty)
|
|
24
|
+
self.size = self.config.size
|
|
25
|
+
self.num_pairs = self.config.num_pairs
|
|
26
|
+
self.grid: list[list[int]] = []
|
|
27
|
+
self.solution: list[list[int]] = []
|
|
28
|
+
self.initial_grid: list[list[int]] = []
|
|
29
|
+
self.endpoints: dict[int, list[tuple[int, int]]] = {}
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def name(self) -> str:
|
|
33
|
+
return "Numberlink"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def description(self) -> str:
|
|
37
|
+
return "Connect numbered pairs with non-crossing paths"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def constraint_types(self) -> list[str]:
|
|
41
|
+
return ["path_connectivity", "non_crossing", "space_filling"]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def business_analogies(self) -> list[str]:
|
|
45
|
+
return ["cable_routing", "circuit_layout", "network_design", "logistics_routing"]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
49
|
+
return {
|
|
50
|
+
"reasoning_type": "deductive",
|
|
51
|
+
"search_space": "large",
|
|
52
|
+
"constraint_density": "dense",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def complexity_metrics(self) -> dict[str, int | float]:
|
|
57
|
+
empty = sum(1 for row in self.grid for cell in row if cell == 0)
|
|
58
|
+
return {
|
|
59
|
+
"variable_count": self.size * self.size,
|
|
60
|
+
"constraint_count": self.num_pairs * 2 + self.size * self.size,
|
|
61
|
+
"domain_size": self.num_pairs,
|
|
62
|
+
"branching_factor": 3.0,
|
|
63
|
+
"empty_cells": empty,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def difficulty_profile(self) -> DifficultyProfile:
|
|
68
|
+
profiles = {
|
|
69
|
+
DifficultyLevel.EASY: DifficultyProfile(
|
|
70
|
+
logic_depth=3, branching_factor=3.0, state_observability=1.0, constraint_density=0.6
|
|
71
|
+
),
|
|
72
|
+
DifficultyLevel.MEDIUM: DifficultyProfile(
|
|
73
|
+
logic_depth=5, branching_factor=3.5, state_observability=1.0, constraint_density=0.5
|
|
74
|
+
),
|
|
75
|
+
DifficultyLevel.HARD: DifficultyProfile(
|
|
76
|
+
logic_depth=7, branching_factor=4.0, state_observability=1.0, constraint_density=0.4
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
return profiles[self.difficulty]
|
|
80
|
+
|
|
81
|
+
def _neighbors(self, r: int, c: int) -> list[tuple[int, int]]:
|
|
82
|
+
"""Get valid orthogonal neighbors."""
|
|
83
|
+
result = []
|
|
84
|
+
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
|
|
85
|
+
nr, nc = r + dr, c + dc
|
|
86
|
+
if 0 <= nr < self.size and 0 <= nc < self.size:
|
|
87
|
+
result.append((nr, nc))
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
def _generate_hamiltonian_path(self) -> list[tuple[int, int]] | None:
|
|
91
|
+
"""Generate a space-filling path that visits all cells.
|
|
92
|
+
|
|
93
|
+
Uses a randomized DFS approach to create a Hamiltonian path.
|
|
94
|
+
"""
|
|
95
|
+
n = self.size
|
|
96
|
+
total = n * n
|
|
97
|
+
visited = [[False] * n for _ in range(n)]
|
|
98
|
+
|
|
99
|
+
# Start from a random cell
|
|
100
|
+
start_r = self._rng.randint(0, n - 1)
|
|
101
|
+
start_c = self._rng.randint(0, n - 1)
|
|
102
|
+
|
|
103
|
+
path: list[tuple[int, int]] = [(start_r, start_c)]
|
|
104
|
+
visited[start_r][start_c] = True
|
|
105
|
+
|
|
106
|
+
def _count_reachable(r: int, c: int) -> int:
|
|
107
|
+
"""Count cells reachable from (r,c) without using visited cells."""
|
|
108
|
+
seen = set()
|
|
109
|
+
queue = deque([(r, c)])
|
|
110
|
+
seen.add((r, c))
|
|
111
|
+
while queue:
|
|
112
|
+
cr, cc = queue.popleft()
|
|
113
|
+
for nr, nc in self._neighbors(cr, cc):
|
|
114
|
+
if not visited[nr][nc] and (nr, nc) not in seen:
|
|
115
|
+
seen.add((nr, nc))
|
|
116
|
+
queue.append((nr, nc))
|
|
117
|
+
return len(seen)
|
|
118
|
+
|
|
119
|
+
while len(path) < total:
|
|
120
|
+
r, c = path[-1]
|
|
121
|
+
neighbors = self._neighbors(r, c)
|
|
122
|
+
unvisited = [(nr, nc) for nr, nc in neighbors if not visited[nr][nc]]
|
|
123
|
+
|
|
124
|
+
if not unvisited:
|
|
125
|
+
return None # Dead end
|
|
126
|
+
|
|
127
|
+
# Warnsdorff's rule: prefer cells with fewer unvisited neighbors
|
|
128
|
+
# (with random tie-breaking)
|
|
129
|
+
def sort_key(pos: tuple[int, int]) -> tuple[int, int]:
|
|
130
|
+
nr, nc = pos
|
|
131
|
+
count = sum(1 for nnr, nnc in self._neighbors(nr, nc) if not visited[nnr][nnc])
|
|
132
|
+
return (count, self._rng.randint(0, 1000))
|
|
133
|
+
|
|
134
|
+
unvisited.sort(key=sort_key)
|
|
135
|
+
nr, nc = unvisited[0]
|
|
136
|
+
path.append((nr, nc))
|
|
137
|
+
visited[nr][nc] = True
|
|
138
|
+
|
|
139
|
+
return path
|
|
140
|
+
|
|
141
|
+
async def generate_puzzle(self) -> None:
|
|
142
|
+
"""Generate a Numberlink puzzle by partitioning a Hamiltonian path."""
|
|
143
|
+
n = self.size
|
|
144
|
+
num_pairs = self.num_pairs
|
|
145
|
+
|
|
146
|
+
# Try to generate a valid Hamiltonian path
|
|
147
|
+
path = None
|
|
148
|
+
for _ in range(50):
|
|
149
|
+
path = self._generate_hamiltonian_path()
|
|
150
|
+
if path and len(path) == n * n:
|
|
151
|
+
break
|
|
152
|
+
path = None
|
|
153
|
+
|
|
154
|
+
if path is None:
|
|
155
|
+
# Fallback: simpler snake path
|
|
156
|
+
path = []
|
|
157
|
+
for r in range(n):
|
|
158
|
+
cols = range(n) if r % 2 == 0 else range(n - 1, -1, -1)
|
|
159
|
+
for c in cols:
|
|
160
|
+
path.append((r, c))
|
|
161
|
+
|
|
162
|
+
total = len(path)
|
|
163
|
+
|
|
164
|
+
# Partition the path into num_pairs segments
|
|
165
|
+
# Calculate segment lengths that sum to total
|
|
166
|
+
min_len = 2 # Each segment must have at least 2 cells
|
|
167
|
+
remaining = total - num_pairs * min_len
|
|
168
|
+
if remaining < 0:
|
|
169
|
+
# Reduce pairs if grid is too small
|
|
170
|
+
num_pairs = total // min_len
|
|
171
|
+
self.num_pairs = num_pairs
|
|
172
|
+
remaining = total - num_pairs * min_len
|
|
173
|
+
|
|
174
|
+
# Distribute extra cells randomly
|
|
175
|
+
extras = [0] * num_pairs
|
|
176
|
+
for _ in range(remaining):
|
|
177
|
+
idx = self._rng.randint(0, num_pairs - 1)
|
|
178
|
+
extras[idx] += 1
|
|
179
|
+
|
|
180
|
+
lengths = [min_len + e for e in extras]
|
|
181
|
+
|
|
182
|
+
# Build solution grid from path segments
|
|
183
|
+
self.solution = [[0] * n for _ in range(n)]
|
|
184
|
+
self.endpoints = {}
|
|
185
|
+
pos = 0
|
|
186
|
+
for pair_id in range(1, num_pairs + 1):
|
|
187
|
+
seg_len = lengths[pair_id - 1]
|
|
188
|
+
segment = path[pos : pos + seg_len]
|
|
189
|
+
start = segment[0]
|
|
190
|
+
end = segment[-1]
|
|
191
|
+
self.endpoints[pair_id] = [start, end]
|
|
192
|
+
for r, c in segment:
|
|
193
|
+
self.solution[r][c] = pair_id
|
|
194
|
+
pos += seg_len
|
|
195
|
+
|
|
196
|
+
# Initial grid: only endpoints are shown
|
|
197
|
+
self.initial_grid = [[0] * n for _ in range(n)]
|
|
198
|
+
for pair_id, pts in self.endpoints.items():
|
|
199
|
+
for r, c in pts:
|
|
200
|
+
self.initial_grid[r][c] = pair_id
|
|
201
|
+
|
|
202
|
+
self.grid = [row[:] for row in self.initial_grid]
|
|
203
|
+
self.game_started = True
|
|
204
|
+
|
|
205
|
+
def _is_endpoint(self, r: int, c: int) -> bool:
|
|
206
|
+
"""Check if (r, c) is an endpoint cell."""
|
|
207
|
+
return self.initial_grid[r][c] != 0
|
|
208
|
+
|
|
209
|
+
async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
|
|
210
|
+
"""Validate placing a path segment.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
row: 1-indexed row
|
|
214
|
+
col: 1-indexed column
|
|
215
|
+
num: Pair number (1-N) or 0 to clear
|
|
216
|
+
"""
|
|
217
|
+
n = self.size
|
|
218
|
+
r, c = row - 1, col - 1
|
|
219
|
+
|
|
220
|
+
if not (0 <= r < n and 0 <= c < n):
|
|
221
|
+
self.record_move((row, col), False)
|
|
222
|
+
return MoveResult(success=False, message=f"Position ({row}, {col}) is out of bounds.")
|
|
223
|
+
|
|
224
|
+
if self._is_endpoint(r, c):
|
|
225
|
+
self.record_move((row, col), False)
|
|
226
|
+
return MoveResult(success=False, message="Cannot modify an endpoint cell.")
|
|
227
|
+
|
|
228
|
+
if num == 0:
|
|
229
|
+
if self.grid[r][c] == 0:
|
|
230
|
+
self.record_move((row, col), False)
|
|
231
|
+
return MoveResult(success=False, message="Cell is already empty.")
|
|
232
|
+
self.grid[r][c] = 0
|
|
233
|
+
self.record_move((row, col), True)
|
|
234
|
+
return MoveResult(success=True, message=f"Cleared cell ({row}, {col}).", state_changed=True)
|
|
235
|
+
|
|
236
|
+
if not (1 <= num <= self.num_pairs):
|
|
237
|
+
self.record_move((row, col), False)
|
|
238
|
+
return MoveResult(success=False, message=f"Pair number must be between 1 and {self.num_pairs}.")
|
|
239
|
+
|
|
240
|
+
self.grid[r][c] = num
|
|
241
|
+
self.record_move((row, col), True)
|
|
242
|
+
return MoveResult(success=True, message=f"Placed {num} at ({row}, {col}).", state_changed=True)
|
|
243
|
+
|
|
244
|
+
def is_complete(self) -> bool:
|
|
245
|
+
"""Check if all paths are correctly connected."""
|
|
246
|
+
return self.grid == self.solution
|
|
247
|
+
|
|
248
|
+
def _check_paths_valid(self) -> bool:
|
|
249
|
+
"""Verify each pair forms a valid connected path."""
|
|
250
|
+
for pair_id, pts in self.endpoints.items():
|
|
251
|
+
start, end = pts
|
|
252
|
+
# BFS from start following cells with this pair_id
|
|
253
|
+
visited = set()
|
|
254
|
+
queue = deque([start])
|
|
255
|
+
visited.add(start)
|
|
256
|
+
while queue:
|
|
257
|
+
r, c = queue.popleft()
|
|
258
|
+
for nr, nc in self._neighbors(r, c):
|
|
259
|
+
if (nr, nc) not in visited and self.grid[nr][nc] == pair_id:
|
|
260
|
+
visited.add((nr, nc))
|
|
261
|
+
queue.append((nr, nc))
|
|
262
|
+
if end not in visited:
|
|
263
|
+
return False
|
|
264
|
+
# Check all cells of this pair_id are connected
|
|
265
|
+
total = sum(1 for row in self.grid for cell in row if cell == pair_id)
|
|
266
|
+
if len(visited) != total:
|
|
267
|
+
return False
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
271
|
+
"""Suggest a cell to fill from the solution."""
|
|
272
|
+
if not self.can_use_hint():
|
|
273
|
+
return None
|
|
274
|
+
n = self.size
|
|
275
|
+
for r in range(n):
|
|
276
|
+
for c in range(n):
|
|
277
|
+
if self.grid[r][c] == 0 and self.solution[r][c] != 0:
|
|
278
|
+
val = self.solution[r][c]
|
|
279
|
+
return (
|
|
280
|
+
(r + 1, c + 1, val),
|
|
281
|
+
f"Try placing {val} at row {r + 1}, column {c + 1}.",
|
|
282
|
+
)
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
def render_grid(self) -> str:
|
|
286
|
+
"""Render the grid showing paths and endpoints."""
|
|
287
|
+
n = self.size
|
|
288
|
+
lines = []
|
|
289
|
+
|
|
290
|
+
# Column headers
|
|
291
|
+
header = " " + " ".join(str(c + 1) for c in range(n))
|
|
292
|
+
lines.append(header)
|
|
293
|
+
lines.append(" " + "+" + "---" * n + "+")
|
|
294
|
+
|
|
295
|
+
for r in range(n):
|
|
296
|
+
cells = []
|
|
297
|
+
for c in range(n):
|
|
298
|
+
val = self.grid[r][c]
|
|
299
|
+
if val == 0:
|
|
300
|
+
cells.append(".")
|
|
301
|
+
elif self._is_endpoint(r, c):
|
|
302
|
+
# Show endpoints in uppercase/bold style
|
|
303
|
+
if val < 10:
|
|
304
|
+
cells.append(str(val))
|
|
305
|
+
else:
|
|
306
|
+
cells.append(chr(ord("A") + val - 10))
|
|
307
|
+
else:
|
|
308
|
+
if val < 10:
|
|
309
|
+
cells.append(str(val))
|
|
310
|
+
else:
|
|
311
|
+
cells.append(chr(ord("a") + val - 10))
|
|
312
|
+
line = f" {r + 1} | " + " ".join(cells) + " |"
|
|
313
|
+
lines.append(line)
|
|
314
|
+
|
|
315
|
+
lines.append(" " + "+" + "---" * n + "+")
|
|
316
|
+
lines.append(f"Pairs: {self.num_pairs}")
|
|
317
|
+
|
|
318
|
+
return "\n".join(lines)
|
|
319
|
+
|
|
320
|
+
def get_stats(self) -> str:
|
|
321
|
+
"""Get current game statistics."""
|
|
322
|
+
filled = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] != 0)
|
|
323
|
+
total = self.size * self.size
|
|
324
|
+
return f"Moves: {self.moves_made} | Filled: {filled}/{total} | Pairs: {self.num_pairs} | Seed: {self.seed}"
|
|
325
|
+
|
|
326
|
+
def get_rules(self) -> str:
|
|
327
|
+
return (
|
|
328
|
+
f"NUMBERLINK ({self.size}x{self.size}, {self.num_pairs} pairs)\n"
|
|
329
|
+
"Connect each pair of matching numbers with a continuous path.\n"
|
|
330
|
+
"Paths travel horizontally or vertically (not diagonally).\n"
|
|
331
|
+
"Paths cannot cross or overlap.\n"
|
|
332
|
+
"Every cell must be part of exactly one path."
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def get_commands(self) -> str:
|
|
336
|
+
return (
|
|
337
|
+
"Commands:\n"
|
|
338
|
+
f" place <row> <col> <pair> - Place a path segment (1-{self.num_pairs})\n"
|
|
339
|
+
" clear <row> <col> - Clear a cell\n"
|
|
340
|
+
" hint - Get a hint\n"
|
|
341
|
+
" check - Check if solved\n"
|
|
342
|
+
" show - Show current state\n"
|
|
343
|
+
" menu - Return to menu"
|
|
344
|
+
)
|