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,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,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])
|