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.
Files changed (55) hide show
  1. chuk_puzzles_gym/eval.py +21 -0
  2. chuk_puzzles_gym/games/__init__.py +22 -0
  3. chuk_puzzles_gym/games/binary/game.py +2 -0
  4. chuk_puzzles_gym/games/bridges/game.py +2 -0
  5. chuk_puzzles_gym/games/cryptarithmetic/__init__.py +7 -0
  6. chuk_puzzles_gym/games/cryptarithmetic/commands.py +75 -0
  7. chuk_puzzles_gym/games/cryptarithmetic/config.py +23 -0
  8. chuk_puzzles_gym/games/cryptarithmetic/game.py +388 -0
  9. chuk_puzzles_gym/games/einstein/game.py +2 -0
  10. chuk_puzzles_gym/games/fillomino/game.py +2 -0
  11. chuk_puzzles_gym/games/futoshiki/game.py +2 -0
  12. chuk_puzzles_gym/games/graph_coloring/__init__.py +7 -0
  13. chuk_puzzles_gym/games/graph_coloring/commands.py +96 -0
  14. chuk_puzzles_gym/games/graph_coloring/config.py +24 -0
  15. chuk_puzzles_gym/games/graph_coloring/game.py +316 -0
  16. chuk_puzzles_gym/games/hidato/game.py +2 -0
  17. chuk_puzzles_gym/games/hitori/game.py +2 -0
  18. chuk_puzzles_gym/games/kakuro/game.py +2 -0
  19. chuk_puzzles_gym/games/kenken/game.py +2 -0
  20. chuk_puzzles_gym/games/killer_sudoku/game.py +2 -0
  21. chuk_puzzles_gym/games/knapsack/game.py +2 -0
  22. chuk_puzzles_gym/games/lights_out/game.py +2 -0
  23. chuk_puzzles_gym/games/logic_grid/game.py +2 -0
  24. chuk_puzzles_gym/games/mastermind/game.py +2 -0
  25. chuk_puzzles_gym/games/minesweeper/game.py +2 -0
  26. chuk_puzzles_gym/games/nonogram/game.py +2 -0
  27. chuk_puzzles_gym/games/nqueens/__init__.py +6 -0
  28. chuk_puzzles_gym/games/nqueens/config.py +23 -0
  29. chuk_puzzles_gym/games/nqueens/game.py +321 -0
  30. chuk_puzzles_gym/games/numberlink/__init__.py +6 -0
  31. chuk_puzzles_gym/games/numberlink/config.py +23 -0
  32. chuk_puzzles_gym/games/numberlink/game.py +344 -0
  33. chuk_puzzles_gym/games/nurikabe/game.py +2 -0
  34. chuk_puzzles_gym/games/rush_hour/__init__.py +8 -0
  35. chuk_puzzles_gym/games/rush_hour/commands.py +57 -0
  36. chuk_puzzles_gym/games/rush_hour/config.py +25 -0
  37. chuk_puzzles_gym/games/rush_hour/game.py +479 -0
  38. chuk_puzzles_gym/games/rush_hour/models.py +15 -0
  39. chuk_puzzles_gym/games/scheduler/game.py +2 -0
  40. chuk_puzzles_gym/games/shikaku/game.py +2 -0
  41. chuk_puzzles_gym/games/skyscrapers/__init__.py +6 -0
  42. chuk_puzzles_gym/games/skyscrapers/config.py +22 -0
  43. chuk_puzzles_gym/games/skyscrapers/game.py +282 -0
  44. chuk_puzzles_gym/games/slitherlink/game.py +2 -0
  45. chuk_puzzles_gym/games/sokoban/game.py +2 -0
  46. chuk_puzzles_gym/games/star_battle/game.py +2 -0
  47. chuk_puzzles_gym/games/sudoku/game.py +2 -0
  48. chuk_puzzles_gym/games/tents/game.py +2 -0
  49. chuk_puzzles_gym/server.py +18 -70
  50. chuk_puzzles_gym/trace/generator.py +87 -0
  51. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/METADATA +60 -19
  52. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/RECORD +55 -33
  53. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/WHEEL +1 -1
  54. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/entry_points.txt +0 -0
  55. {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,6 @@
1
+ """Numberlink (Flow) puzzle game."""
2
+
3
+ from .config import NumberlinkConfig
4
+ from .game import NumberlinkGame
5
+
6
+ __all__ = ["NumberlinkGame", "NumberlinkConfig"]
@@ -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
+ )