chuk-puzzles-gym 0.9__py3-none-any.whl → 0.10__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 (31) hide show
  1. chuk_puzzles_gym/eval.py +21 -0
  2. chuk_puzzles_gym/games/__init__.py +22 -0
  3. chuk_puzzles_gym/games/cryptarithmetic/__init__.py +7 -0
  4. chuk_puzzles_gym/games/cryptarithmetic/commands.py +75 -0
  5. chuk_puzzles_gym/games/cryptarithmetic/config.py +23 -0
  6. chuk_puzzles_gym/games/cryptarithmetic/game.py +383 -0
  7. chuk_puzzles_gym/games/graph_coloring/__init__.py +7 -0
  8. chuk_puzzles_gym/games/graph_coloring/commands.py +79 -0
  9. chuk_puzzles_gym/games/graph_coloring/config.py +24 -0
  10. chuk_puzzles_gym/games/graph_coloring/game.py +309 -0
  11. chuk_puzzles_gym/games/nqueens/__init__.py +6 -0
  12. chuk_puzzles_gym/games/nqueens/config.py +23 -0
  13. chuk_puzzles_gym/games/nqueens/game.py +316 -0
  14. chuk_puzzles_gym/games/numberlink/__init__.py +6 -0
  15. chuk_puzzles_gym/games/numberlink/config.py +23 -0
  16. chuk_puzzles_gym/games/numberlink/game.py +338 -0
  17. chuk_puzzles_gym/games/rush_hour/__init__.py +8 -0
  18. chuk_puzzles_gym/games/rush_hour/commands.py +57 -0
  19. chuk_puzzles_gym/games/rush_hour/config.py +25 -0
  20. chuk_puzzles_gym/games/rush_hour/game.py +475 -0
  21. chuk_puzzles_gym/games/rush_hour/models.py +15 -0
  22. chuk_puzzles_gym/games/skyscrapers/__init__.py +6 -0
  23. chuk_puzzles_gym/games/skyscrapers/config.py +22 -0
  24. chuk_puzzles_gym/games/skyscrapers/game.py +277 -0
  25. chuk_puzzles_gym/server.py +1 -1
  26. chuk_puzzles_gym/trace/generator.py +87 -0
  27. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/METADATA +60 -19
  28. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/RECORD +31 -9
  29. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/WHEEL +1 -1
  30. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/entry_points.txt +0 -0
  31. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/top_level.txt +0 -0
@@ -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,338 @@
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_rules(self) -> str:
321
+ return (
322
+ f"NUMBERLINK ({self.size}x{self.size}, {self.num_pairs} pairs)\n"
323
+ "Connect each pair of matching numbers with a continuous path.\n"
324
+ "Paths travel horizontally or vertically (not diagonally).\n"
325
+ "Paths cannot cross or overlap.\n"
326
+ "Every cell must be part of exactly one path."
327
+ )
328
+
329
+ def get_commands(self) -> str:
330
+ return (
331
+ "Commands:\n"
332
+ f" place <row> <col> <pair> - Place a path segment (1-{self.num_pairs})\n"
333
+ " clear <row> <col> - Clear a cell\n"
334
+ " hint - Get a hint\n"
335
+ " check - Check if solved\n"
336
+ " show - Show current state\n"
337
+ " menu - Return to menu"
338
+ )
@@ -0,0 +1,8 @@
1
+ """Rush Hour puzzle game."""
2
+
3
+ from .commands import RushHourCommandHandler
4
+ from .config import RushHourConfig
5
+ from .game import RushHourGame
6
+ from .models import Vehicle
7
+
8
+ __all__ = ["RushHourGame", "RushHourConfig", "RushHourCommandHandler", "Vehicle"]
@@ -0,0 +1,57 @@
1
+ """Command handler for Rush Hour game."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ...models import GameCommand, MoveResult
6
+ from .._base import CommandResult, GameCommandHandler
7
+
8
+ if TYPE_CHECKING:
9
+ from .game import RushHourGame
10
+
11
+
12
+ class RushHourCommandHandler(GameCommandHandler):
13
+ """Handles commands for Rush Hour game."""
14
+
15
+ game: "RushHourGame"
16
+
17
+ @property
18
+ def supported_commands(self) -> set[GameCommand]:
19
+ """Return the set of GameCommand enums this handler supports."""
20
+ return {GameCommand.MOVE}
21
+
22
+ async def handle_command(self, cmd: GameCommand, args: list[str]) -> CommandResult:
23
+ """Handle a Rush Hour command.
24
+
25
+ Args:
26
+ cmd: The GameCommand enum value
27
+ args: List of string arguments (already split from input)
28
+
29
+ Returns:
30
+ CommandResult with the move result and display flags
31
+ """
32
+ if cmd == GameCommand.MOVE:
33
+ return await self._handle_move(args)
34
+ else:
35
+ return self.error_result(f"Unknown command: {cmd}")
36
+
37
+ async def _handle_move(self, args: list[str]) -> CommandResult:
38
+ """Handle the MOVE command: move <vehicle> <direction>."""
39
+ if len(args) != 2:
40
+ return CommandResult(
41
+ result=MoveResult(
42
+ success=False,
43
+ message="Usage: move <vehicle> <direction>\nDirections: left, right, up, down",
44
+ ),
45
+ should_display=False,
46
+ )
47
+
48
+ vehicle_id = args[0].upper()
49
+ direction = args[1].lower()
50
+
51
+ result = await self.game.validate_move(vehicle_id, direction)
52
+
53
+ return CommandResult(
54
+ result=result,
55
+ should_display=result.success,
56
+ is_game_over=result.game_over,
57
+ )
@@ -0,0 +1,25 @@
1
+ """Configuration for Rush Hour puzzle game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models import DifficultyLevel
6
+
7
+
8
+ class RushHourConfig(BaseModel):
9
+ """Configuration for a Rush Hour puzzle."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY)
12
+ size: int = Field(default=6, ge=6, le=8, description="Board size")
13
+ num_vehicles: int = Field(ge=2, le=15, description="Number of blocking vehicles")
14
+ min_moves: int = Field(ge=1, description="Minimum solution moves for difficulty")
15
+ max_moves: int = Field(ge=1, description="Maximum solution moves for difficulty")
16
+
17
+ @classmethod
18
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "RushHourConfig":
19
+ """Create config from difficulty level."""
20
+ config_map = {
21
+ DifficultyLevel.EASY: {"size": 6, "num_vehicles": 4, "min_moves": 3, "max_moves": 12},
22
+ DifficultyLevel.MEDIUM: {"size": 6, "num_vehicles": 8, "min_moves": 8, "max_moves": 25},
23
+ DifficultyLevel.HARD: {"size": 6, "num_vehicles": 12, "min_moves": 15, "max_moves": 50},
24
+ }
25
+ return cls(difficulty=difficulty, **config_map[difficulty])