chuk-puzzles-gym 0.9__py3-none-any.whl → 0.10.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chuk_puzzles_gym/eval.py +21 -0
- chuk_puzzles_gym/games/__init__.py +22 -0
- chuk_puzzles_gym/games/binary/game.py +2 -0
- chuk_puzzles_gym/games/bridges/game.py +2 -0
- chuk_puzzles_gym/games/cryptarithmetic/__init__.py +7 -0
- chuk_puzzles_gym/games/cryptarithmetic/commands.py +75 -0
- chuk_puzzles_gym/games/cryptarithmetic/config.py +23 -0
- chuk_puzzles_gym/games/cryptarithmetic/game.py +388 -0
- chuk_puzzles_gym/games/einstein/game.py +2 -0
- chuk_puzzles_gym/games/fillomino/game.py +2 -0
- chuk_puzzles_gym/games/futoshiki/game.py +2 -0
- chuk_puzzles_gym/games/graph_coloring/__init__.py +7 -0
- chuk_puzzles_gym/games/graph_coloring/commands.py +96 -0
- chuk_puzzles_gym/games/graph_coloring/config.py +24 -0
- chuk_puzzles_gym/games/graph_coloring/game.py +316 -0
- chuk_puzzles_gym/games/hidato/game.py +2 -0
- chuk_puzzles_gym/games/hitori/game.py +2 -0
- chuk_puzzles_gym/games/kakuro/game.py +2 -0
- chuk_puzzles_gym/games/kenken/game.py +2 -0
- chuk_puzzles_gym/games/killer_sudoku/game.py +2 -0
- chuk_puzzles_gym/games/knapsack/game.py +2 -0
- chuk_puzzles_gym/games/lights_out/game.py +2 -0
- chuk_puzzles_gym/games/logic_grid/game.py +2 -0
- chuk_puzzles_gym/games/mastermind/game.py +2 -0
- chuk_puzzles_gym/games/minesweeper/game.py +2 -0
- chuk_puzzles_gym/games/nonogram/game.py +2 -0
- chuk_puzzles_gym/games/nqueens/__init__.py +6 -0
- chuk_puzzles_gym/games/nqueens/config.py +23 -0
- chuk_puzzles_gym/games/nqueens/game.py +321 -0
- chuk_puzzles_gym/games/numberlink/__init__.py +6 -0
- chuk_puzzles_gym/games/numberlink/config.py +23 -0
- chuk_puzzles_gym/games/numberlink/game.py +344 -0
- chuk_puzzles_gym/games/nurikabe/game.py +2 -0
- chuk_puzzles_gym/games/rush_hour/__init__.py +8 -0
- chuk_puzzles_gym/games/rush_hour/commands.py +57 -0
- chuk_puzzles_gym/games/rush_hour/config.py +25 -0
- chuk_puzzles_gym/games/rush_hour/game.py +479 -0
- chuk_puzzles_gym/games/rush_hour/models.py +15 -0
- chuk_puzzles_gym/games/scheduler/game.py +2 -0
- chuk_puzzles_gym/games/shikaku/game.py +2 -0
- chuk_puzzles_gym/games/skyscrapers/__init__.py +6 -0
- chuk_puzzles_gym/games/skyscrapers/config.py +22 -0
- chuk_puzzles_gym/games/skyscrapers/game.py +282 -0
- chuk_puzzles_gym/games/slitherlink/game.py +2 -0
- chuk_puzzles_gym/games/sokoban/game.py +2 -0
- chuk_puzzles_gym/games/star_battle/game.py +2 -0
- chuk_puzzles_gym/games/sudoku/game.py +2 -0
- chuk_puzzles_gym/games/tents/game.py +2 -0
- chuk_puzzles_gym/server.py +18 -70
- chuk_puzzles_gym/trace/generator.py +87 -0
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/METADATA +60 -19
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/RECORD +55 -33
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/WHEEL +1 -1
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/entry_points.txt +0 -0
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/top_level.txt +0 -0
|
@@ -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,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,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])
|