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.
- chuk_puzzles_gym/eval.py +21 -0
- chuk_puzzles_gym/games/__init__.py +22 -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 +383 -0
- chuk_puzzles_gym/games/graph_coloring/__init__.py +7 -0
- chuk_puzzles_gym/games/graph_coloring/commands.py +79 -0
- chuk_puzzles_gym/games/graph_coloring/config.py +24 -0
- chuk_puzzles_gym/games/graph_coloring/game.py +309 -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 +316 -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 +338 -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 +475 -0
- chuk_puzzles_gym/games/rush_hour/models.py +15 -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 +277 -0
- chuk_puzzles_gym/server.py +1 -1
- chuk_puzzles_gym/trace/generator.py +87 -0
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/METADATA +60 -19
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/RECORD +31 -9
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/WHEEL +1 -1
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/entry_points.txt +0 -0
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,475 @@
|
|
|
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_rules(self) -> str:
|
|
458
|
+
return (
|
|
459
|
+
f"RUSH HOUR ({self.size}x{self.size})\n"
|
|
460
|
+
"Slide vehicles to let the target car (X) reach the exit (>).\n"
|
|
461
|
+
"Horizontal vehicles move left/right only.\n"
|
|
462
|
+
"Vertical vehicles move up/down only.\n"
|
|
463
|
+
"Vehicles cannot pass through each other.\n"
|
|
464
|
+
"Move X to the right edge to win."
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def get_commands(self) -> str:
|
|
468
|
+
return (
|
|
469
|
+
"Commands:\n"
|
|
470
|
+
" move <vehicle> <direction> - Slide a vehicle (left/right/up/down)\n"
|
|
471
|
+
" hint - Get a hint\n"
|
|
472
|
+
" check - Check if solved\n"
|
|
473
|
+
" show - Show current state\n"
|
|
474
|
+
" menu - Return to menu"
|
|
475
|
+
)
|
|
@@ -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")
|
|
@@ -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])
|