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
@@ -479,6 +479,8 @@ class NurikabeGame(PuzzleGame):
479
479
  Returns:
480
480
  Tuple of (hint_data, hint_message) or None
481
481
  """
482
+ if not self.can_use_hint():
483
+ return None
482
484
  # Find a cell that differs from solution
483
485
  for row in range(self.size):
484
486
  for col in range(self.size):
@@ -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])
@@ -0,0 +1,479 @@
1
+ """Rush Hour 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 RushHourConfig
9
+ from .models import Vehicle
10
+
11
+ VEHICLE_IDS = "ABCDEFGHIJKLMNOPQRSTUVWYZ" # Skip X (reserved for target)
12
+
13
+
14
+ class RushHourGame(PuzzleGame):
15
+ """Rush Hour puzzle - slide vehicles to let the target car exit.
16
+
17
+ Rules:
18
+ - Vehicles occupy 2 or 3 cells and can only move along their orientation
19
+ - Horizontal vehicles move left/right, vertical vehicles move up/down
20
+ - Vehicles cannot pass through each other
21
+ - Move the target car (X) to the right edge to win
22
+ """
23
+
24
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
25
+ super().__init__(difficulty, seed, **kwargs)
26
+ self.config = RushHourConfig.from_difficulty(self.difficulty)
27
+ self.size = self.config.size
28
+ self.vehicles: dict[str, Vehicle] = {}
29
+ self.grid: list[list[str]] = []
30
+ self.initial_grid: list[list[str]] = []
31
+ self.exit_row = 2 # Target car always on row 2 (0-indexed)
32
+ self.min_solution_moves: int | None = None
33
+
34
+ @property
35
+ def name(self) -> str:
36
+ return "Rush Hour"
37
+
38
+ @property
39
+ def description(self) -> str:
40
+ return "Slide vehicles to free the target car (X) to the exit"
41
+
42
+ @property
43
+ def constraint_types(self) -> list[str]:
44
+ return ["sequential_planning", "spatial_blocking", "search", "irreversible_actions"]
45
+
46
+ @property
47
+ def business_analogies(self) -> list[str]:
48
+ return ["traffic_management", "warehouse_logistics", "deadlock_resolution"]
49
+
50
+ @property
51
+ def complexity_profile(self) -> dict[str, str]:
52
+ return {
53
+ "reasoning_type": "planning",
54
+ "search_space": "large",
55
+ "constraint_density": "moderate",
56
+ }
57
+
58
+ @property
59
+ def complexity_metrics(self) -> dict[str, int | float]:
60
+ return {
61
+ "variable_count": len(self.vehicles),
62
+ "constraint_count": len(self.vehicles) * 2,
63
+ "domain_size": self.size,
64
+ "branching_factor": len(self.vehicles) * 2.0,
65
+ "empty_cells": sum(1 for row in self.grid for cell in row if cell == "."),
66
+ }
67
+
68
+ @property
69
+ def difficulty_profile(self) -> DifficultyProfile:
70
+ profiles = {
71
+ DifficultyLevel.EASY: DifficultyProfile(
72
+ logic_depth=3, branching_factor=6.0, state_observability=1.0, constraint_density=0.4
73
+ ),
74
+ DifficultyLevel.MEDIUM: DifficultyProfile(
75
+ logic_depth=6, branching_factor=10.0, state_observability=1.0, constraint_density=0.5
76
+ ),
77
+ DifficultyLevel.HARD: DifficultyProfile(
78
+ logic_depth=10, branching_factor=15.0, state_observability=1.0, constraint_density=0.6
79
+ ),
80
+ }
81
+ return profiles[self.difficulty]
82
+
83
+ @property
84
+ def optimal_steps(self) -> int | None:
85
+ return self.min_solution_moves
86
+
87
+ def _build_grid(self) -> list[list[str]]:
88
+ """Build the grid from current vehicle positions."""
89
+ grid = [["." for _ in range(self.size)] for _ in range(self.size)]
90
+ for vid, v in self.vehicles.items():
91
+ for i in range(v.length):
92
+ if v.orientation == "h":
93
+ grid[v.row][v.col + i] = vid
94
+ else:
95
+ grid[v.row + i][v.col] = vid
96
+ return grid
97
+
98
+ def _can_place_vehicle(self, grid: list[list[str]], row: int, col: int, length: int, orientation: str) -> bool:
99
+ """Check if a vehicle can be placed at the given position."""
100
+ for i in range(length):
101
+ if orientation == "h":
102
+ r, c = row, col + i
103
+ else:
104
+ r, c = row + i, col
105
+ if not (0 <= r < self.size and 0 <= c < self.size):
106
+ return False
107
+ if grid[r][c] != ".":
108
+ return False
109
+ return True
110
+
111
+ def _get_state_tuple(self) -> tuple:
112
+ """Get a hashable state representation for BFS."""
113
+ return tuple((v.id, v.row, v.col) for v in sorted(self.vehicles.values(), key=lambda x: x.id))
114
+
115
+ def _solve_bfs(self) -> int | None:
116
+ """Find minimum moves to solve using BFS.
117
+
118
+ Returns:
119
+ Minimum number of moves, or None if unsolvable.
120
+ """
121
+ initial_state = self._get_state_tuple()
122
+ queue: deque[tuple[tuple, int, dict[str, Vehicle]]] = deque()
123
+ queue.append((initial_state, 0, dict(self.vehicles)))
124
+ visited: set[tuple] = {initial_state}
125
+
126
+ while queue:
127
+ state, moves, vehicles = queue.popleft()
128
+
129
+ # Check if target car reached exit
130
+ target = vehicles["X"]
131
+ if target.col + target.length - 1 >= self.size - 1:
132
+ return moves
133
+
134
+ # Limit search depth
135
+ if moves >= 60:
136
+ continue
137
+
138
+ # Build grid for this state
139
+ grid = [["." for _ in range(self.size)] for _ in range(self.size)]
140
+ for vid, v in vehicles.items():
141
+ for i in range(v.length):
142
+ if v.orientation == "h":
143
+ grid[v.row][v.col + i] = vid
144
+ else:
145
+ grid[v.row + i][v.col] = vid
146
+
147
+ # Try all possible moves
148
+ for vid, v in vehicles.items():
149
+ if v.orientation == "h":
150
+ # Try moving left
151
+ if v.col > 0 and grid[v.row][v.col - 1] == ".":
152
+ new_vehicles = dict(vehicles)
153
+ new_vehicles[vid] = Vehicle(id=vid, row=v.row, col=v.col - 1, length=v.length, orientation="h")
154
+ new_state = tuple(
155
+ (vv.id, vv.row, vv.col) for vv in sorted(new_vehicles.values(), key=lambda x: x.id)
156
+ )
157
+ if new_state not in visited:
158
+ visited.add(new_state)
159
+ queue.append((new_state, moves + 1, new_vehicles))
160
+ # Try moving right
161
+ if v.col + v.length < self.size and grid[v.row][v.col + v.length] == ".":
162
+ new_vehicles = dict(vehicles)
163
+ new_vehicles[vid] = Vehicle(id=vid, row=v.row, col=v.col + 1, length=v.length, orientation="h")
164
+ new_state = tuple(
165
+ (vv.id, vv.row, vv.col) for vv in sorted(new_vehicles.values(), key=lambda x: x.id)
166
+ )
167
+ if new_state not in visited:
168
+ visited.add(new_state)
169
+ queue.append((new_state, moves + 1, new_vehicles))
170
+ else: # vertical
171
+ # Try moving up
172
+ if v.row > 0 and grid[v.row - 1][v.col] == ".":
173
+ new_vehicles = dict(vehicles)
174
+ new_vehicles[vid] = Vehicle(id=vid, row=v.row - 1, col=v.col, length=v.length, orientation="v")
175
+ new_state = tuple(
176
+ (vv.id, vv.row, vv.col) for vv in sorted(new_vehicles.values(), key=lambda x: x.id)
177
+ )
178
+ if new_state not in visited:
179
+ visited.add(new_state)
180
+ queue.append((new_state, moves + 1, new_vehicles))
181
+ # Try moving down
182
+ if v.row + v.length < self.size and grid[v.row + v.length][v.col] == ".":
183
+ new_vehicles = dict(vehicles)
184
+ new_vehicles[vid] = Vehicle(id=vid, row=v.row + 1, col=v.col, length=v.length, orientation="v")
185
+ new_state = tuple(
186
+ (vv.id, vv.row, vv.col) for vv in sorted(new_vehicles.values(), key=lambda x: x.id)
187
+ )
188
+ if new_state not in visited:
189
+ visited.add(new_state)
190
+ queue.append((new_state, moves + 1, new_vehicles))
191
+
192
+ return None
193
+
194
+ async def generate_puzzle(self) -> None:
195
+ """Generate a Rush Hour puzzle."""
196
+ size = self.size
197
+ num_vehicles = self.config.num_vehicles
198
+ min_moves = self.config.min_moves
199
+ max_moves = self.config.max_moves
200
+
201
+ best_puzzle: dict[str, Vehicle] | None = None
202
+ best_moves: int | None = None
203
+
204
+ for _ in range(100):
205
+ self.vehicles = {}
206
+
207
+ # Place target car (X) on exit row, random starting column
208
+ max_start_col = size - 3 # Leave room to not already be at exit
209
+ start_col = self._rng.randint(0, max(0, max_start_col))
210
+ self.vehicles["X"] = Vehicle(id="X", row=self.exit_row, col=start_col, length=2, orientation="h")
211
+
212
+ grid = self._build_grid()
213
+
214
+ # Place blocking vehicles
215
+ placed = 0
216
+ attempts = 0
217
+ while placed < num_vehicles and attempts < 200:
218
+ attempts += 1
219
+ vid = VEHICLE_IDS[placed] if placed < len(VEHICLE_IDS) else chr(ord("a") + placed - len(VEHICLE_IDS))
220
+ orientation = self._rng.choice(["h", "v"])
221
+ length = self._rng.choice([2, 2, 3]) # More 2-length vehicles
222
+ row = self._rng.randint(0, size - 1)
223
+ col = self._rng.randint(0, size - 1)
224
+
225
+ if self._can_place_vehicle(grid, row, col, length, orientation):
226
+ self.vehicles[vid] = Vehicle(id=vid, row=row, col=col, length=length, orientation=orientation)
227
+ for i in range(length):
228
+ if orientation == "h":
229
+ grid[row][col + i] = vid
230
+ else:
231
+ grid[row + i][col] = vid
232
+ placed += 1
233
+
234
+ # Verify solvability and difficulty
235
+ solution_moves = self._solve_bfs()
236
+ if solution_moves is not None and min_moves <= solution_moves <= max_moves:
237
+ best_puzzle = dict(self.vehicles)
238
+ best_moves = solution_moves
239
+ break
240
+ elif solution_moves is not None:
241
+ # Keep the best puzzle found so far
242
+ if best_puzzle is None or (
243
+ best_moves is not None and abs(solution_moves - min_moves) < abs(best_moves - min_moves)
244
+ ):
245
+ best_puzzle = dict(self.vehicles)
246
+ best_moves = solution_moves
247
+
248
+ if best_puzzle is not None:
249
+ self.vehicles = best_puzzle
250
+ self.min_solution_moves = best_moves
251
+ else:
252
+ # Minimal fallback: just target car, no blockers, already solvable
253
+ self.vehicles = {"X": Vehicle(id="X", row=self.exit_row, col=0, length=2, orientation="h")}
254
+ self.min_solution_moves = self.size - 2
255
+
256
+ self.grid = self._build_grid()
257
+ self.initial_grid = [row[:] for row in self.grid]
258
+ self.game_started = True
259
+
260
+ async def validate_move(self, vehicle_id: str, direction: str) -> MoveResult:
261
+ """Validate sliding a vehicle.
262
+
263
+ Args:
264
+ vehicle_id: Vehicle letter (e.g., 'X', 'A')
265
+ direction: 'up', 'down', 'left', 'right'
266
+ """
267
+ vehicle_id = vehicle_id.upper()
268
+ direction = direction.lower()
269
+
270
+ if vehicle_id not in self.vehicles:
271
+ self.record_move((vehicle_id,), False)
272
+ available = ", ".join(sorted(self.vehicles.keys()))
273
+ return MoveResult(success=False, message=f"No vehicle '{vehicle_id}'. Available: {available}")
274
+
275
+ vehicle = self.vehicles[vehicle_id]
276
+ valid_directions = {"h": {"left", "right"}, "v": {"up", "down"}}
277
+
278
+ if direction not in valid_directions[vehicle.orientation]:
279
+ self.record_move((vehicle_id,), False)
280
+ orient_name = "horizontal" if vehicle.orientation == "h" else "vertical"
281
+ valid = " or ".join(valid_directions[vehicle.orientation])
282
+ return MoveResult(
283
+ success=False,
284
+ message=f"Vehicle {vehicle_id} is {orient_name}. Use: {valid}",
285
+ )
286
+
287
+ # Calculate new position
288
+ new_row, new_col = vehicle.row, vehicle.col
289
+ if direction == "left":
290
+ new_col -= 1
291
+ elif direction == "right":
292
+ new_col += 1
293
+ elif direction == "up":
294
+ new_row -= 1
295
+ elif direction == "down":
296
+ new_row += 1
297
+
298
+ # Check bounds
299
+ if vehicle.orientation == "h":
300
+ if new_col < 0 or new_col + vehicle.length > self.size:
301
+ self.record_move((vehicle_id,), False)
302
+ return MoveResult(success=False, message=f"Vehicle {vehicle_id} cannot move {direction} - wall.")
303
+ else:
304
+ if new_row < 0 or new_row + vehicle.length > self.size:
305
+ self.record_move((vehicle_id,), False)
306
+ return MoveResult(success=False, message=f"Vehicle {vehicle_id} cannot move {direction} - wall.")
307
+
308
+ # Check for collisions
309
+ # First, clear current vehicle from grid
310
+ temp_grid = [row[:] for row in self.grid]
311
+ for i in range(vehicle.length):
312
+ if vehicle.orientation == "h":
313
+ temp_grid[vehicle.row][vehicle.col + i] = "."
314
+ else:
315
+ temp_grid[vehicle.row + i][vehicle.col] = "."
316
+
317
+ # Check new position
318
+ for i in range(vehicle.length):
319
+ if vehicle.orientation == "h":
320
+ r, c = new_row, new_col + i
321
+ else:
322
+ r, c = new_row + i, new_col
323
+ if temp_grid[r][c] != ".":
324
+ self.record_move((vehicle_id,), False)
325
+ return MoveResult(
326
+ success=False,
327
+ message=f"Vehicle {vehicle_id} blocked by {temp_grid[r][c]}.",
328
+ )
329
+
330
+ # Apply move
331
+ new_vehicle = Vehicle(
332
+ id=vehicle_id,
333
+ row=new_row,
334
+ col=new_col,
335
+ length=vehicle.length,
336
+ orientation=vehicle.orientation,
337
+ )
338
+ self.vehicles[vehicle_id] = new_vehicle
339
+ self.grid = self._build_grid()
340
+ self.record_move((vehicle_id,), True)
341
+
342
+ msg = f"Moved {vehicle_id} {direction}."
343
+ game_over = self.is_complete()
344
+ if game_over:
345
+ msg += " Vehicle X has reached the exit!"
346
+
347
+ return MoveResult(success=True, message=msg, state_changed=True, game_over=game_over)
348
+
349
+ def is_complete(self) -> bool:
350
+ """Check if target car has reached the exit."""
351
+ target = self.vehicles.get("X")
352
+ if target is None:
353
+ return False
354
+ return target.col + target.length >= self.size
355
+
356
+ async def get_hint(self) -> tuple[Any, str] | None:
357
+ """Suggest a move by running BFS from current state."""
358
+ if not self.can_use_hint():
359
+ return None
360
+
361
+ # Run BFS to find next move
362
+ initial_state = self._get_state_tuple()
363
+ queue: deque[tuple[tuple, list[tuple[str, str]], dict[str, Vehicle]]] = deque()
364
+ queue.append((initial_state, [], dict(self.vehicles)))
365
+ visited: set[tuple] = {initial_state}
366
+
367
+ while queue:
368
+ state, moves_list, vehicles = queue.popleft()
369
+
370
+ target = vehicles["X"]
371
+ if target.col + target.length >= self.size:
372
+ if moves_list:
373
+ vid, direction = moves_list[0]
374
+ return ((vid, direction), f"Try moving vehicle {vid} {direction}.")
375
+ return None
376
+
377
+ if len(moves_list) >= 30:
378
+ continue
379
+
380
+ grid = [["." for _ in range(self.size)] for _ in range(self.size)]
381
+ for vid, v in vehicles.items():
382
+ for i in range(v.length):
383
+ if v.orientation == "h":
384
+ grid[v.row][v.col + i] = vid
385
+ else:
386
+ grid[v.row + i][v.col] = vid
387
+
388
+ for vid, v in vehicles.items():
389
+ for direction, dr, dc in [("left", 0, -1), ("right", 0, 1), ("up", -1, 0), ("down", 1, 0)]:
390
+ if v.orientation == "h" and direction in ("up", "down"):
391
+ continue
392
+ if v.orientation == "v" and direction in ("left", "right"):
393
+ continue
394
+
395
+ new_row, new_col = v.row + dr, v.col + dc
396
+ if v.orientation == "h" and (new_col < 0 or new_col + v.length > self.size):
397
+ continue
398
+ if v.orientation == "v" and (new_row < 0 or new_row + v.length > self.size):
399
+ continue
400
+
401
+ blocked = False
402
+ for i in range(v.length):
403
+ if v.orientation == "h":
404
+ r, c = new_row, new_col + i
405
+ else:
406
+ r, c = new_row + i, new_col
407
+ if grid[r][c] != "." and grid[r][c] != vid:
408
+ blocked = True
409
+ break
410
+ if blocked:
411
+ continue
412
+
413
+ new_vehicles = dict(vehicles)
414
+ new_vehicles[vid] = Vehicle(
415
+ id=vid, row=new_row, col=new_col, length=v.length, orientation=v.orientation
416
+ )
417
+ new_state = tuple(
418
+ (vv.id, vv.row, vv.col) for vv in sorted(new_vehicles.values(), key=lambda x: x.id)
419
+ )
420
+ if new_state not in visited:
421
+ visited.add(new_state)
422
+ queue.append((new_state, moves_list + [(vid, direction)], new_vehicles))
423
+
424
+ return None
425
+
426
+ def render_grid(self) -> str:
427
+ """Render the Rush Hour board."""
428
+ lines = []
429
+ lines.append(f"Rush Hour ({self.size}x{self.size})")
430
+ if self.min_solution_moves is not None:
431
+ lines.append(f"Minimum solution: {self.min_solution_moves} moves")
432
+ lines.append("")
433
+
434
+ # Column headers
435
+ header = " " + " ".join(str(c + 1) for c in range(self.size))
436
+ lines.append(header)
437
+ lines.append(" " + "+" + "--" * self.size + "+")
438
+
439
+ for r in range(self.size):
440
+ cells = " ".join(self.grid[r])
441
+ exit_marker = " >" if r == self.exit_row else " "
442
+ lines.append(f" {r + 1} | {cells} |{exit_marker}")
443
+
444
+ lines.append(" " + "+" + "--" * self.size + "+")
445
+
446
+ # Vehicle legend
447
+ lines.append("")
448
+ lines.append("Vehicles:")
449
+ for vid in sorted(self.vehicles.keys()):
450
+ v = self.vehicles[vid]
451
+ orient = "horizontal" if v.orientation == "h" else "vertical"
452
+ target = " (TARGET)" if vid == "X" else ""
453
+ lines.append(f" {vid}: {orient}, length {v.length}{target}")
454
+
455
+ return "\n".join(lines)
456
+
457
+ def get_stats(self) -> str:
458
+ """Get current game statistics."""
459
+ return f"Moves: {self.moves_made} | Vehicles: {len(self.vehicles)} | Grid: {self.size}x{self.size} | Seed: {self.seed}"
460
+
461
+ def get_rules(self) -> str:
462
+ return (
463
+ f"RUSH HOUR ({self.size}x{self.size})\n"
464
+ "Slide vehicles to let the target car (X) reach the exit (>).\n"
465
+ "Horizontal vehicles move left/right only.\n"
466
+ "Vertical vehicles move up/down only.\n"
467
+ "Vehicles cannot pass through each other.\n"
468
+ "Move X to the right edge to win."
469
+ )
470
+
471
+ def get_commands(self) -> str:
472
+ return (
473
+ "Commands:\n"
474
+ " move <vehicle> <direction> - Slide a vehicle (left/right/up/down)\n"
475
+ " hint - Get a hint\n"
476
+ " check - Check if solved\n"
477
+ " show - Show current state\n"
478
+ " menu - Return to menu"
479
+ )
@@ -0,0 +1,15 @@
1
+ """Models for Rush Hour puzzle game."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class Vehicle(BaseModel):
7
+ """A vehicle on the Rush Hour board."""
8
+
9
+ model_config = ConfigDict(frozen=True)
10
+
11
+ id: str = Field(min_length=1, max_length=1, description="Vehicle identifier (letter)")
12
+ row: int = Field(ge=0, description="Top-left row position")
13
+ col: int = Field(ge=0, description="Top-left column position")
14
+ length: int = Field(ge=2, le=3, description="Vehicle length (2 or 3)")
15
+ orientation: str = Field(description="'h' for horizontal, 'v' for vertical")
@@ -316,6 +316,8 @@ class SchedulerGame(PuzzleGame):
316
316
  Returns:
317
317
  Tuple of (hint_data, hint_message) or None
318
318
  """
319
+ if not self.can_use_hint():
320
+ return None
319
321
  # Find an unscheduled task that's in the optimal solution
320
322
  for task_id in range(self.num_tasks):
321
323
  if task_id not in self.schedule and task_id in self.optimal_schedule:
@@ -327,6 +327,8 @@ class ShikakuGame(PuzzleGame):
327
327
 
328
328
  async def get_hint(self) -> tuple[Any, str] | None:
329
329
  """Get a hint for the next move."""
330
+ if not self.can_use_hint():
331
+ return None
330
332
  # Find a rectangle from the solution that hasn't been placed yet
331
333
  solution_rects: dict[int, list[tuple[int, int]]] = {}
332
334
  for r in range(self.size):
@@ -0,0 +1,6 @@
1
+ """Skyscrapers puzzle game."""
2
+
3
+ from .config import SkyscrapersConfig
4
+ from .game import SkyscrapersGame
5
+
6
+ __all__ = ["SkyscrapersGame", "SkyscrapersConfig"]
@@ -0,0 +1,22 @@
1
+ """Configuration for Skyscrapers puzzle game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models import DifficultyLevel
6
+
7
+
8
+ class SkyscrapersConfig(BaseModel):
9
+ """Configuration for a Skyscrapers puzzle."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY)
12
+ size: int = Field(ge=4, le=9, description="Grid size (NxN)")
13
+
14
+ @classmethod
15
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "SkyscrapersConfig":
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
+ return cls(difficulty=difficulty, **config_map[difficulty])