chuk-puzzles-gym 0.9__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/__init__.py +19 -0
- chuk_puzzles_gym/constants.py +9 -0
- chuk_puzzles_gym/eval.py +763 -0
- chuk_puzzles_gym/export/__init__.py +20 -0
- chuk_puzzles_gym/export/dataset.py +376 -0
- chuk_puzzles_gym/games/__init__.py +94 -0
- chuk_puzzles_gym/games/_base/__init__.py +6 -0
- chuk_puzzles_gym/games/_base/commands.py +91 -0
- chuk_puzzles_gym/games/_base/game.py +337 -0
- chuk_puzzles_gym/games/binary/__init__.py +6 -0
- chuk_puzzles_gym/games/binary/config.py +23 -0
- chuk_puzzles_gym/games/binary/game.py +434 -0
- chuk_puzzles_gym/games/bridges/__init__.py +6 -0
- chuk_puzzles_gym/games/bridges/config.py +24 -0
- chuk_puzzles_gym/games/bridges/game.py +489 -0
- chuk_puzzles_gym/games/einstein/__init__.py +6 -0
- chuk_puzzles_gym/games/einstein/config.py +23 -0
- chuk_puzzles_gym/games/einstein/constants.py +13 -0
- chuk_puzzles_gym/games/einstein/game.py +366 -0
- chuk_puzzles_gym/games/einstein/models.py +35 -0
- chuk_puzzles_gym/games/fillomino/__init__.py +6 -0
- chuk_puzzles_gym/games/fillomino/config.py +24 -0
- chuk_puzzles_gym/games/fillomino/game.py +516 -0
- chuk_puzzles_gym/games/futoshiki/__init__.py +6 -0
- chuk_puzzles_gym/games/futoshiki/config.py +23 -0
- chuk_puzzles_gym/games/futoshiki/game.py +391 -0
- chuk_puzzles_gym/games/hidato/__init__.py +6 -0
- chuk_puzzles_gym/games/hidato/config.py +24 -0
- chuk_puzzles_gym/games/hidato/game.py +403 -0
- chuk_puzzles_gym/games/hitori/__init__.py +6 -0
- chuk_puzzles_gym/games/hitori/config.py +23 -0
- chuk_puzzles_gym/games/hitori/game.py +451 -0
- chuk_puzzles_gym/games/kakuro/__init__.py +6 -0
- chuk_puzzles_gym/games/kakuro/config.py +24 -0
- chuk_puzzles_gym/games/kakuro/game.py +399 -0
- chuk_puzzles_gym/games/kenken/__init__.py +6 -0
- chuk_puzzles_gym/games/kenken/config.py +24 -0
- chuk_puzzles_gym/games/kenken/enums.py +13 -0
- chuk_puzzles_gym/games/kenken/game.py +486 -0
- chuk_puzzles_gym/games/kenken/models.py +15 -0
- chuk_puzzles_gym/games/killer_sudoku/__init__.py +6 -0
- chuk_puzzles_gym/games/killer_sudoku/config.py +23 -0
- chuk_puzzles_gym/games/killer_sudoku/game.py +502 -0
- chuk_puzzles_gym/games/killer_sudoku/models.py +15 -0
- chuk_puzzles_gym/games/knapsack/__init__.py +6 -0
- chuk_puzzles_gym/games/knapsack/config.py +24 -0
- chuk_puzzles_gym/games/knapsack/enums.py +10 -0
- chuk_puzzles_gym/games/knapsack/game.py +340 -0
- chuk_puzzles_gym/games/knapsack/models.py +13 -0
- chuk_puzzles_gym/games/lights_out/__init__.py +6 -0
- chuk_puzzles_gym/games/lights_out/config.py +24 -0
- chuk_puzzles_gym/games/lights_out/game.py +249 -0
- chuk_puzzles_gym/games/logic_grid/__init__.py +6 -0
- chuk_puzzles_gym/games/logic_grid/config.py +24 -0
- chuk_puzzles_gym/games/logic_grid/constants.py +12 -0
- chuk_puzzles_gym/games/logic_grid/game.py +333 -0
- chuk_puzzles_gym/games/logic_grid/models.py +24 -0
- chuk_puzzles_gym/games/mastermind/__init__.py +6 -0
- chuk_puzzles_gym/games/mastermind/config.py +25 -0
- chuk_puzzles_gym/games/mastermind/game.py +297 -0
- chuk_puzzles_gym/games/minesweeper/__init__.py +6 -0
- chuk_puzzles_gym/games/minesweeper/config.py +24 -0
- chuk_puzzles_gym/games/minesweeper/enums.py +12 -0
- chuk_puzzles_gym/games/minesweeper/game.py +432 -0
- chuk_puzzles_gym/games/nonogram/__init__.py +6 -0
- chuk_puzzles_gym/games/nonogram/config.py +23 -0
- chuk_puzzles_gym/games/nonogram/game.py +296 -0
- chuk_puzzles_gym/games/nurikabe/__init__.py +6 -0
- chuk_puzzles_gym/games/nurikabe/config.py +24 -0
- chuk_puzzles_gym/games/nurikabe/enums.py +14 -0
- chuk_puzzles_gym/games/nurikabe/game.py +586 -0
- chuk_puzzles_gym/games/scheduler/__init__.py +6 -0
- chuk_puzzles_gym/games/scheduler/config.py +25 -0
- chuk_puzzles_gym/games/scheduler/constants.py +15 -0
- chuk_puzzles_gym/games/scheduler/enums.py +10 -0
- chuk_puzzles_gym/games/scheduler/game.py +431 -0
- chuk_puzzles_gym/games/scheduler/models.py +14 -0
- chuk_puzzles_gym/games/shikaku/__init__.py +6 -0
- chuk_puzzles_gym/games/shikaku/config.py +24 -0
- chuk_puzzles_gym/games/shikaku/game.py +419 -0
- chuk_puzzles_gym/games/slitherlink/__init__.py +6 -0
- chuk_puzzles_gym/games/slitherlink/config.py +23 -0
- chuk_puzzles_gym/games/slitherlink/game.py +386 -0
- chuk_puzzles_gym/games/sokoban/__init__.py +6 -0
- chuk_puzzles_gym/games/sokoban/config.py +24 -0
- chuk_puzzles_gym/games/sokoban/game.py +671 -0
- chuk_puzzles_gym/games/star_battle/__init__.py +6 -0
- chuk_puzzles_gym/games/star_battle/config.py +24 -0
- chuk_puzzles_gym/games/star_battle/game.py +390 -0
- chuk_puzzles_gym/games/sudoku/__init__.py +7 -0
- chuk_puzzles_gym/games/sudoku/commands.py +96 -0
- chuk_puzzles_gym/games/sudoku/config.py +22 -0
- chuk_puzzles_gym/games/sudoku/game.py +328 -0
- chuk_puzzles_gym/games/tents/__init__.py +6 -0
- chuk_puzzles_gym/games/tents/config.py +24 -0
- chuk_puzzles_gym/games/tents/game.py +416 -0
- chuk_puzzles_gym/gym_env.py +465 -0
- chuk_puzzles_gym/models/__init__.py +47 -0
- chuk_puzzles_gym/models/base.py +30 -0
- chuk_puzzles_gym/models/config.py +11 -0
- chuk_puzzles_gym/models/enums.py +104 -0
- chuk_puzzles_gym/models/evaluation.py +487 -0
- chuk_puzzles_gym/models/games.py +12 -0
- chuk_puzzles_gym/server.py +1171 -0
- chuk_puzzles_gym/trace/__init__.py +10 -0
- chuk_puzzles_gym/trace/generator.py +726 -0
- chuk_puzzles_gym/utils/__init__.py +4 -0
- chuk_puzzles_gym-0.9.dist-info/METADATA +1471 -0
- chuk_puzzles_gym-0.9.dist-info/RECORD +112 -0
- chuk_puzzles_gym-0.9.dist-info/WHEEL +5 -0
- chuk_puzzles_gym-0.9.dist-info/entry_points.txt +4 -0
- chuk_puzzles_gym-0.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""Sokoban puzzle game implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ...models import DifficultyProfile, MoveResult
|
|
6
|
+
from .._base import PuzzleGame
|
|
7
|
+
from .config import SokobanConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SokobanGame(PuzzleGame):
|
|
11
|
+
"""Sokoban puzzle game.
|
|
12
|
+
|
|
13
|
+
Push boxes to goal positions:
|
|
14
|
+
- Player can move in 4 directions
|
|
15
|
+
- Player can push boxes (but not pull them)
|
|
16
|
+
- Boxes cannot be pushed through walls or other boxes
|
|
17
|
+
- Goal: Get all boxes onto goal positions
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
21
|
+
"""Initialize a new Sokoban game.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
difficulty: Game difficulty level (easy=6x6, medium=8x8, hard=10x10)
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
27
|
+
|
|
28
|
+
# Use pydantic config based on difficulty
|
|
29
|
+
self.config = SokobanConfig.from_difficulty(self.difficulty)
|
|
30
|
+
self.size = self.config.size
|
|
31
|
+
self.num_boxes = self.config.num_boxes
|
|
32
|
+
|
|
33
|
+
# Grid: 0 = empty, 1 = wall, 2 = box, 3 = goal, 4 = player
|
|
34
|
+
# Box on goal = 5, Player on goal = 6
|
|
35
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
36
|
+
self.goals: list[tuple[int, int]] = []
|
|
37
|
+
self.player_pos: tuple[int, int] = (0, 0)
|
|
38
|
+
self.initial_state: dict[str, Any] = {}
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def name(self) -> str:
|
|
42
|
+
"""The display name of this puzzle type."""
|
|
43
|
+
return "Sokoban"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def description(self) -> str:
|
|
47
|
+
"""A one-line description of this puzzle type."""
|
|
48
|
+
return "Push boxes to goal positions - planning and spatial reasoning"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def constraint_types(self) -> list[str]:
|
|
52
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
53
|
+
return ["irreversible_actions", "spatial_planning", "goal_states", "path_finding"]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def business_analogies(self) -> list[str]:
|
|
57
|
+
"""Business problems this puzzle models."""
|
|
58
|
+
return ["warehouse_logistics", "movement_planning", "resource_positioning", "sequential_operations"]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
62
|
+
"""Complexity profile of this puzzle."""
|
|
63
|
+
return {"reasoning_type": "optimization", "search_space": "exponential", "constraint_density": "sparse"}
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def optimal_steps(self) -> int | None:
|
|
67
|
+
"""Minimum steps estimate = box pushes + player repositioning moves."""
|
|
68
|
+
if not hasattr(self, "goals") or not self.goals:
|
|
69
|
+
return None
|
|
70
|
+
# Find all boxes in the grid (2 = box, 5 = box on goal)
|
|
71
|
+
boxes = []
|
|
72
|
+
for r in range(self.size):
|
|
73
|
+
for c in range(self.size):
|
|
74
|
+
if self.grid[r][c] in (2, 5):
|
|
75
|
+
boxes.append((r, c))
|
|
76
|
+
if not boxes:
|
|
77
|
+
return None
|
|
78
|
+
# Each box push is 1 move. Player needs to get behind each box.
|
|
79
|
+
# Estimate: sum of box distances + (num_boxes - 1) for repositioning
|
|
80
|
+
total_pushes = 0
|
|
81
|
+
for box in boxes:
|
|
82
|
+
min_dist = min(abs(box[0] - g[0]) + abs(box[1] - g[1]) for g in self.goals)
|
|
83
|
+
total_pushes += min_dist
|
|
84
|
+
# Add repositioning moves between boxes (rough estimate)
|
|
85
|
+
reposition = max(0, len(boxes) - 1) * 2
|
|
86
|
+
return total_pushes + reposition
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
90
|
+
"""Difficulty characteristics for Sokoban."""
|
|
91
|
+
from ...models import DifficultyLevel
|
|
92
|
+
|
|
93
|
+
logic_depth = {
|
|
94
|
+
DifficultyLevel.EASY.value: 3,
|
|
95
|
+
DifficultyLevel.MEDIUM.value: 5,
|
|
96
|
+
DifficultyLevel.HARD.value: 8,
|
|
97
|
+
}.get(self.difficulty.value, 5)
|
|
98
|
+
return DifficultyProfile(
|
|
99
|
+
logic_depth=logic_depth,
|
|
100
|
+
branching_factor=4.0, # 4 directions
|
|
101
|
+
state_observability=1.0,
|
|
102
|
+
constraint_density=0.4,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _is_corner(self, r: int, c: int) -> bool:
|
|
106
|
+
"""Check if a position is a corner (would trap a box)."""
|
|
107
|
+
# Check all four corner configurations
|
|
108
|
+
corners = [
|
|
109
|
+
[(0, -1), (-1, 0)], # top-left corner
|
|
110
|
+
[(0, 1), (-1, 0)], # top-right corner
|
|
111
|
+
[(0, -1), (1, 0)], # bottom-left corner
|
|
112
|
+
[(0, 1), (1, 0)], # bottom-right corner
|
|
113
|
+
]
|
|
114
|
+
for (dr1, dc1), (dr2, dc2) in corners:
|
|
115
|
+
nr1, nc1 = r + dr1, c + dc1
|
|
116
|
+
nr2, nc2 = r + dr2, c + dc2
|
|
117
|
+
# Check if both adjacent cells are walls
|
|
118
|
+
wall1 = not (0 <= nr1 < self.size and 0 <= nc1 < self.size) or self.grid[nr1][nc1] == 1
|
|
119
|
+
wall2 = not (0 <= nr2 < self.size and 0 <= nc2 < self.size) or self.grid[nr2][nc2] == 1
|
|
120
|
+
if wall1 and wall2:
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
def _can_push_to_goal(self, box_r: int, box_c: int, goal_r: int, goal_c: int) -> bool:
|
|
125
|
+
"""Check if a box can be pushed from box position to goal position.
|
|
126
|
+
|
|
127
|
+
For simple evaluation, we require box and goal to be on same row or column
|
|
128
|
+
with no walls between them and push space available.
|
|
129
|
+
"""
|
|
130
|
+
if box_r == goal_r:
|
|
131
|
+
# Same row - check horizontal push
|
|
132
|
+
if box_c < goal_c:
|
|
133
|
+
# Push right - need empty space to left of box
|
|
134
|
+
if box_c - 1 < 1:
|
|
135
|
+
return False
|
|
136
|
+
if self.grid[box_r][box_c - 1] != 0:
|
|
137
|
+
return False
|
|
138
|
+
# Check path is clear
|
|
139
|
+
for c in range(box_c + 1, goal_c + 1):
|
|
140
|
+
if self.grid[box_r][c] == 1:
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
143
|
+
else:
|
|
144
|
+
# Push left - need empty space to right of box
|
|
145
|
+
if box_c + 1 >= self.size - 1:
|
|
146
|
+
return False
|
|
147
|
+
if self.grid[box_r][box_c + 1] != 0:
|
|
148
|
+
return False
|
|
149
|
+
# Check path is clear
|
|
150
|
+
for c in range(goal_c, box_c):
|
|
151
|
+
if self.grid[box_r][c] == 1:
|
|
152
|
+
return False
|
|
153
|
+
return True
|
|
154
|
+
elif box_c == goal_c:
|
|
155
|
+
# Same column - check vertical push
|
|
156
|
+
if box_r < goal_r:
|
|
157
|
+
# Push down - need empty space above box
|
|
158
|
+
if box_r - 1 < 1:
|
|
159
|
+
return False
|
|
160
|
+
if self.grid[box_r - 1][box_c] != 0:
|
|
161
|
+
return False
|
|
162
|
+
# Check path is clear
|
|
163
|
+
for r in range(box_r + 1, goal_r + 1):
|
|
164
|
+
if self.grid[r][box_c] == 1:
|
|
165
|
+
return False
|
|
166
|
+
return True
|
|
167
|
+
else:
|
|
168
|
+
# Push up - need empty space below box
|
|
169
|
+
if box_r + 1 >= self.size - 1:
|
|
170
|
+
return False
|
|
171
|
+
if self.grid[box_r + 1][box_c] != 0:
|
|
172
|
+
return False
|
|
173
|
+
# Check path is clear
|
|
174
|
+
for r in range(goal_r, box_r):
|
|
175
|
+
if self.grid[r][box_c] == 1:
|
|
176
|
+
return False
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
async def generate_puzzle(self) -> None:
|
|
181
|
+
"""Generate a new Sokoban puzzle that is guaranteed solvable.
|
|
182
|
+
|
|
183
|
+
Strategy: Place goals first, then place boxes in positions where
|
|
184
|
+
they can be directly pushed to goals in a straight line.
|
|
185
|
+
"""
|
|
186
|
+
max_attempts = 50
|
|
187
|
+
|
|
188
|
+
for _attempt in range(max_attempts):
|
|
189
|
+
# Create a simple room with walls (only borders, no internal walls for simplicity)
|
|
190
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
191
|
+
|
|
192
|
+
# Add border walls
|
|
193
|
+
for i in range(self.size):
|
|
194
|
+
self.grid[0][i] = 1
|
|
195
|
+
self.grid[self.size - 1][i] = 1
|
|
196
|
+
self.grid[i][0] = 1
|
|
197
|
+
self.grid[i][self.size - 1] = 1
|
|
198
|
+
|
|
199
|
+
# Place goals in safe positions (not in corners, not on edges next to corners)
|
|
200
|
+
self.goals = []
|
|
201
|
+
goal_attempts = 0
|
|
202
|
+
while len(self.goals) < self.num_boxes and goal_attempts < 100:
|
|
203
|
+
goal_attempts += 1
|
|
204
|
+
# Place goals in the interior, avoiding positions too close to walls
|
|
205
|
+
r = self._rng.randint(2, self.size - 3)
|
|
206
|
+
c = self._rng.randint(2, self.size - 3)
|
|
207
|
+
if self.grid[r][c] == 0 and (r, c) not in self.goals:
|
|
208
|
+
self.goals.append((r, c))
|
|
209
|
+
self.grid[r][c] = 3 # Mark as goal
|
|
210
|
+
|
|
211
|
+
if len(self.goals) < self.num_boxes:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
# For each goal, place a box that can be directly pushed to it
|
|
215
|
+
boxes_placed = []
|
|
216
|
+
all_boxes_valid = True
|
|
217
|
+
|
|
218
|
+
for goal_r, goal_c in self.goals:
|
|
219
|
+
# Try to place box in a position where it can be pushed to goal
|
|
220
|
+
placed = False
|
|
221
|
+
|
|
222
|
+
# Try each direction: place box such that pushing will move it to goal
|
|
223
|
+
# Shuffle directions for variety
|
|
224
|
+
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
|
225
|
+
self._rng.shuffle(directions)
|
|
226
|
+
|
|
227
|
+
for dr, dc in directions:
|
|
228
|
+
# Box position (offset from goal)
|
|
229
|
+
distance = self._rng.randint(1, 2) # 1-2 cells away
|
|
230
|
+
box_r = goal_r - dr * distance
|
|
231
|
+
box_c = goal_c - dc * distance
|
|
232
|
+
|
|
233
|
+
# Push position (where player needs to be to push)
|
|
234
|
+
push_r = box_r - dr
|
|
235
|
+
push_c = box_c - dc
|
|
236
|
+
|
|
237
|
+
# Check all positions are valid
|
|
238
|
+
if not (1 <= box_r < self.size - 1 and 1 <= box_c < self.size - 1):
|
|
239
|
+
continue
|
|
240
|
+
if not (1 <= push_r < self.size - 1 and 1 <= push_c < self.size - 1):
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
# Check box position is empty and not a corner
|
|
244
|
+
if self.grid[box_r][box_c] != 0:
|
|
245
|
+
continue
|
|
246
|
+
if (box_r, box_c) in boxes_placed:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# Check push position is empty
|
|
250
|
+
if self.grid[push_r][push_c] != 0:
|
|
251
|
+
continue
|
|
252
|
+
if (push_r, push_c) in boxes_placed:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
# Check path from box to goal is clear (only goals allowed)
|
|
256
|
+
path_clear = True
|
|
257
|
+
if dr != 0:
|
|
258
|
+
step = 1 if dr > 0 else -1
|
|
259
|
+
for r in range(box_r + step, goal_r + step, step):
|
|
260
|
+
if self.grid[r][box_c] not in [0, 3]:
|
|
261
|
+
path_clear = False
|
|
262
|
+
break
|
|
263
|
+
if (r, box_c) in boxes_placed:
|
|
264
|
+
path_clear = False
|
|
265
|
+
break
|
|
266
|
+
else:
|
|
267
|
+
step = 1 if dc > 0 else -1
|
|
268
|
+
for c in range(box_c + step, goal_c + step, step):
|
|
269
|
+
if self.grid[box_r][c] not in [0, 3]:
|
|
270
|
+
path_clear = False
|
|
271
|
+
break
|
|
272
|
+
if (box_r, c) in boxes_placed:
|
|
273
|
+
path_clear = False
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
if path_clear:
|
|
277
|
+
boxes_placed.append((box_r, box_c))
|
|
278
|
+
placed = True
|
|
279
|
+
break
|
|
280
|
+
|
|
281
|
+
if not placed:
|
|
282
|
+
all_boxes_valid = False
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
if not all_boxes_valid:
|
|
286
|
+
# Reset and try again
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
# Place all boxes
|
|
290
|
+
for box_r, box_c in boxes_placed:
|
|
291
|
+
self.grid[box_r][box_c] = 2
|
|
292
|
+
|
|
293
|
+
# Find a suitable player position
|
|
294
|
+
# Player should be able to reach push positions
|
|
295
|
+
player_placed = False
|
|
296
|
+
player_candidates = []
|
|
297
|
+
|
|
298
|
+
for r in range(1, self.size - 1):
|
|
299
|
+
for c in range(1, self.size - 1):
|
|
300
|
+
if self.grid[r][c] == 0:
|
|
301
|
+
player_candidates.append((r, c))
|
|
302
|
+
|
|
303
|
+
if player_candidates:
|
|
304
|
+
self._rng.shuffle(player_candidates)
|
|
305
|
+
self.player_pos = player_candidates[0]
|
|
306
|
+
self.grid[self.player_pos[0]][self.player_pos[1]] = 4
|
|
307
|
+
player_placed = True
|
|
308
|
+
|
|
309
|
+
if player_placed:
|
|
310
|
+
# Store initial state
|
|
311
|
+
self.initial_state = {
|
|
312
|
+
"grid": [row[:] for row in self.grid],
|
|
313
|
+
"player_pos": self.player_pos,
|
|
314
|
+
}
|
|
315
|
+
self.moves_made = 0
|
|
316
|
+
self.game_started = True
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
# Fallback: create a trivially simple puzzle
|
|
320
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
321
|
+
|
|
322
|
+
# Add border walls
|
|
323
|
+
for i in range(self.size):
|
|
324
|
+
self.grid[0][i] = 1
|
|
325
|
+
self.grid[self.size - 1][i] = 1
|
|
326
|
+
self.grid[i][0] = 1
|
|
327
|
+
self.grid[i][self.size - 1] = 1
|
|
328
|
+
|
|
329
|
+
# Place goals and boxes in a simple line pattern
|
|
330
|
+
self.goals = []
|
|
331
|
+
mid = self.size // 2
|
|
332
|
+
|
|
333
|
+
for i in range(self.num_boxes):
|
|
334
|
+
goal_r = mid
|
|
335
|
+
goal_c = 2 + i * 2
|
|
336
|
+
if goal_c < self.size - 2:
|
|
337
|
+
self.goals.append((goal_r, goal_c))
|
|
338
|
+
self.grid[goal_r][goal_c] = 3
|
|
339
|
+
# Box one cell above
|
|
340
|
+
self.grid[goal_r - 1][goal_c] = 2
|
|
341
|
+
|
|
342
|
+
# Player at bottom
|
|
343
|
+
self.player_pos = (mid + 1, 2)
|
|
344
|
+
self.grid[self.player_pos[0]][self.player_pos[1]] = 4
|
|
345
|
+
|
|
346
|
+
self.initial_state = {
|
|
347
|
+
"grid": [row[:] for row in self.grid],
|
|
348
|
+
"player_pos": self.player_pos,
|
|
349
|
+
}
|
|
350
|
+
self.moves_made = 0
|
|
351
|
+
self.game_started = True
|
|
352
|
+
|
|
353
|
+
async def validate_move(self, direction: str) -> MoveResult:
|
|
354
|
+
"""Move the player in a direction.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
direction: Direction to move ("up", "down", "left", "right")
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
MoveResult with success status and message
|
|
361
|
+
"""
|
|
362
|
+
direction = direction.lower()
|
|
363
|
+
|
|
364
|
+
# Map direction to delta
|
|
365
|
+
direction_map = {
|
|
366
|
+
"up": (-1, 0),
|
|
367
|
+
"down": (1, 0),
|
|
368
|
+
"left": (0, -1),
|
|
369
|
+
"right": (0, 1),
|
|
370
|
+
"u": (-1, 0),
|
|
371
|
+
"d": (1, 0),
|
|
372
|
+
"l": (0, -1),
|
|
373
|
+
"r": (0, 1),
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if direction not in direction_map:
|
|
377
|
+
return MoveResult(success=False, message="Invalid direction. Use: up, down, left, right")
|
|
378
|
+
|
|
379
|
+
dr, dc = direction_map[direction]
|
|
380
|
+
curr_r, curr_c = self.player_pos
|
|
381
|
+
new_r, new_c = curr_r + dr, curr_c + dc
|
|
382
|
+
|
|
383
|
+
# Check bounds
|
|
384
|
+
if not (0 <= new_r < self.size and 0 <= new_c < self.size):
|
|
385
|
+
return MoveResult(success=False, message="Cannot move outside the grid.")
|
|
386
|
+
|
|
387
|
+
# Check what's at the new position
|
|
388
|
+
target_cell = self.grid[new_r][new_c]
|
|
389
|
+
|
|
390
|
+
# Wall
|
|
391
|
+
if target_cell == 1:
|
|
392
|
+
return MoveResult(success=False, message="Cannot move into a wall.")
|
|
393
|
+
|
|
394
|
+
# Empty or goal
|
|
395
|
+
if target_cell in [0, 3]:
|
|
396
|
+
# Move player
|
|
397
|
+
# Clear current position
|
|
398
|
+
on_goal = any(curr_r == gr and curr_c == gc for gr, gc in self.goals)
|
|
399
|
+
self.grid[curr_r][curr_c] = 3 if on_goal else 0
|
|
400
|
+
|
|
401
|
+
# Set new position
|
|
402
|
+
on_goal = any(new_r == gr and new_c == gc for gr, gc in self.goals)
|
|
403
|
+
self.grid[new_r][new_c] = 6 if on_goal else 4
|
|
404
|
+
|
|
405
|
+
self.player_pos = (new_r, new_c)
|
|
406
|
+
self.moves_made += 1
|
|
407
|
+
return MoveResult(success=True, message=f"Moved {direction}.", state_changed=True)
|
|
408
|
+
|
|
409
|
+
# Box or box on goal
|
|
410
|
+
if target_cell in [2, 5]:
|
|
411
|
+
# Try to push the box
|
|
412
|
+
push_r, push_c = new_r + dr, new_c + dc
|
|
413
|
+
|
|
414
|
+
# Check push destination
|
|
415
|
+
if not (0 <= push_r < self.size and 0 <= push_c < self.size):
|
|
416
|
+
return MoveResult(success=False, message="Cannot push box outside the grid.")
|
|
417
|
+
|
|
418
|
+
push_target = self.grid[push_r][push_c]
|
|
419
|
+
|
|
420
|
+
# Can only push into empty or goal
|
|
421
|
+
if push_target not in [0, 3]:
|
|
422
|
+
return MoveResult(success=False, message="Cannot push box into wall or another box.")
|
|
423
|
+
|
|
424
|
+
# Push the box
|
|
425
|
+
# Clear current position
|
|
426
|
+
on_goal = any(curr_r == gr and curr_c == gc for gr, gc in self.goals)
|
|
427
|
+
self.grid[curr_r][curr_c] = 3 if on_goal else 0
|
|
428
|
+
|
|
429
|
+
# Move player to box position
|
|
430
|
+
box_on_goal = any(new_r == gr and new_c == gc for gr, gc in self.goals)
|
|
431
|
+
self.grid[new_r][new_c] = 6 if box_on_goal else 4
|
|
432
|
+
|
|
433
|
+
# Move box to push position
|
|
434
|
+
push_on_goal = any(push_r == gr and push_c == gc for gr, gc in self.goals)
|
|
435
|
+
self.grid[push_r][push_c] = 5 if push_on_goal else 2
|
|
436
|
+
|
|
437
|
+
self.player_pos = (new_r, new_c)
|
|
438
|
+
self.moves_made += 1
|
|
439
|
+
return MoveResult(success=True, message=f"Pushed box {direction}.", state_changed=True)
|
|
440
|
+
|
|
441
|
+
return MoveResult(success=False, message="Unknown cell type.")
|
|
442
|
+
|
|
443
|
+
def is_complete(self) -> bool:
|
|
444
|
+
"""Check if the puzzle is complete (all boxes on goals)."""
|
|
445
|
+
# Check if all goals have boxes
|
|
446
|
+
for gr, gc in self.goals:
|
|
447
|
+
cell = self.grid[gr][gc]
|
|
448
|
+
# Box on goal (5) or player on goal with box (not possible in standard rules)
|
|
449
|
+
if cell != 5 and cell != 6: # Goal must have box
|
|
450
|
+
# Check if there's a box here
|
|
451
|
+
if cell != 5:
|
|
452
|
+
return False
|
|
453
|
+
return True
|
|
454
|
+
|
|
455
|
+
def _find_path_to_push_position(self, target_r: int, target_c: int) -> list[str] | None:
|
|
456
|
+
"""Use BFS to find path from player to target position.
|
|
457
|
+
|
|
458
|
+
Returns list of directions or None if no path exists.
|
|
459
|
+
"""
|
|
460
|
+
from collections import deque
|
|
461
|
+
|
|
462
|
+
start = self.player_pos
|
|
463
|
+
if start == (target_r, target_c):
|
|
464
|
+
return []
|
|
465
|
+
|
|
466
|
+
direction_map = {"up": (-1, 0), "down": (1, 0), "left": (0, -1), "right": (0, 1)}
|
|
467
|
+
visited = {start}
|
|
468
|
+
queue: deque[tuple[tuple[int, int], list[str]]] = deque([(start, [])])
|
|
469
|
+
|
|
470
|
+
while queue:
|
|
471
|
+
(r, c), path = queue.popleft()
|
|
472
|
+
|
|
473
|
+
for direction, (dr, dc) in direction_map.items():
|
|
474
|
+
nr, nc = r + dr, c + dc
|
|
475
|
+
|
|
476
|
+
if (nr, nc) in visited:
|
|
477
|
+
continue
|
|
478
|
+
if not (0 <= nr < self.size and 0 <= nc < self.size):
|
|
479
|
+
continue
|
|
480
|
+
|
|
481
|
+
cell = self.grid[nr][nc]
|
|
482
|
+
# Can only move through empty cells and goals
|
|
483
|
+
if cell not in [0, 3]:
|
|
484
|
+
continue
|
|
485
|
+
|
|
486
|
+
if (nr, nc) == (target_r, target_c):
|
|
487
|
+
return path + [direction]
|
|
488
|
+
|
|
489
|
+
visited.add((nr, nc))
|
|
490
|
+
queue.append(((nr, nc), path + [direction]))
|
|
491
|
+
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
495
|
+
"""Get a hint for the next move.
|
|
496
|
+
|
|
497
|
+
Uses BFS to find the optimal move to push boxes toward goals.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
Tuple of (hint_data, hint_message) or None
|
|
501
|
+
"""
|
|
502
|
+
if self.is_complete():
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
direction_map = {"up": (-1, 0), "down": (1, 0), "left": (0, -1), "right": (0, 1)}
|
|
506
|
+
|
|
507
|
+
# Find boxes not on goals and unfilled goals
|
|
508
|
+
boxes_not_on_goal = []
|
|
509
|
+
unfilled_goals = []
|
|
510
|
+
|
|
511
|
+
for r in range(self.size):
|
|
512
|
+
for c in range(self.size):
|
|
513
|
+
if self.grid[r][c] == 2:
|
|
514
|
+
boxes_not_on_goal.append((r, c))
|
|
515
|
+
|
|
516
|
+
for gr, gc in self.goals:
|
|
517
|
+
if self.grid[gr][gc] != 5: # Not box-on-goal
|
|
518
|
+
unfilled_goals.append((gr, gc))
|
|
519
|
+
|
|
520
|
+
if not boxes_not_on_goal:
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
# For each box, find the best push direction toward a goal
|
|
524
|
+
best_hint = None
|
|
525
|
+
best_score = float("inf")
|
|
526
|
+
|
|
527
|
+
for box_r, box_c in boxes_not_on_goal:
|
|
528
|
+
for goal_r, goal_c in unfilled_goals:
|
|
529
|
+
# Determine push direction needed
|
|
530
|
+
push_dir = None
|
|
531
|
+
if box_r == goal_r:
|
|
532
|
+
if box_c < goal_c:
|
|
533
|
+
push_dir = "right"
|
|
534
|
+
elif box_c > goal_c:
|
|
535
|
+
push_dir = "left"
|
|
536
|
+
elif box_c == goal_c:
|
|
537
|
+
if box_r < goal_r:
|
|
538
|
+
push_dir = "down"
|
|
539
|
+
elif box_r > goal_r:
|
|
540
|
+
push_dir = "up"
|
|
541
|
+
|
|
542
|
+
if push_dir is None:
|
|
543
|
+
continue
|
|
544
|
+
|
|
545
|
+
dr, dc = direction_map[push_dir]
|
|
546
|
+
# Player needs to be on opposite side of box to push
|
|
547
|
+
push_pos_r = box_r - dr
|
|
548
|
+
push_pos_c = box_c - dc
|
|
549
|
+
|
|
550
|
+
# Check if push position is valid
|
|
551
|
+
if not (1 <= push_pos_r < self.size - 1 and 1 <= push_pos_c < self.size - 1):
|
|
552
|
+
continue
|
|
553
|
+
if self.grid[push_pos_r][push_pos_c] == 1: # Wall
|
|
554
|
+
continue
|
|
555
|
+
|
|
556
|
+
# Check if we can actually push (destination is clear)
|
|
557
|
+
dest_r, dest_c = box_r + dr, box_c + dc
|
|
558
|
+
if not (0 <= dest_r < self.size and 0 <= dest_c < self.size):
|
|
559
|
+
continue
|
|
560
|
+
if self.grid[dest_r][dest_c] not in [0, 3]: # Not empty/goal
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
# If player is already at push position, push is the hint
|
|
564
|
+
if self.player_pos == (push_pos_r, push_pos_c):
|
|
565
|
+
score = abs(goal_r - dest_r) + abs(goal_c - dest_c)
|
|
566
|
+
if score < best_score:
|
|
567
|
+
best_score = score
|
|
568
|
+
best_hint = (push_dir, f"Push {push_dir} to move box toward goal")
|
|
569
|
+
|
|
570
|
+
# Otherwise, find path to push position
|
|
571
|
+
elif self.grid[push_pos_r][push_pos_c] in [0, 3]:
|
|
572
|
+
path = self._find_path_to_push_position(push_pos_r, push_pos_c)
|
|
573
|
+
if path:
|
|
574
|
+
score = len(path) + abs(goal_r - dest_r) + abs(goal_c - dest_c)
|
|
575
|
+
if score < best_score:
|
|
576
|
+
best_score = score
|
|
577
|
+
best_hint = (path[0], f"Move {path[0]} to get into push position")
|
|
578
|
+
|
|
579
|
+
if best_hint:
|
|
580
|
+
return best_hint
|
|
581
|
+
|
|
582
|
+
# Fallback: try any valid move
|
|
583
|
+
curr_r, curr_c = self.player_pos
|
|
584
|
+
for direction, (dr, dc) in direction_map.items():
|
|
585
|
+
new_r, new_c = curr_r + dr, curr_c + dc
|
|
586
|
+
if 0 <= new_r < self.size and 0 <= new_c < self.size:
|
|
587
|
+
cell = self.grid[new_r][new_c]
|
|
588
|
+
if cell in [0, 3]: # Empty or goal
|
|
589
|
+
return direction, f"Try moving {direction}"
|
|
590
|
+
elif cell in [2, 5]: # Box
|
|
591
|
+
push_r, push_c = new_r + dr, new_c + dc
|
|
592
|
+
if 0 <= push_r < self.size and 0 <= push_c < self.size:
|
|
593
|
+
if self.grid[push_r][push_c] in [0, 3]:
|
|
594
|
+
return direction, f"Try pushing {direction}"
|
|
595
|
+
|
|
596
|
+
return None
|
|
597
|
+
|
|
598
|
+
def render_grid(self) -> str:
|
|
599
|
+
"""Render the current puzzle state as ASCII art.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
String representation of the puzzle grid
|
|
603
|
+
"""
|
|
604
|
+
lines = []
|
|
605
|
+
|
|
606
|
+
for r in range(self.size):
|
|
607
|
+
row_str = ""
|
|
608
|
+
for c in range(self.size):
|
|
609
|
+
cell = self.grid[r][c]
|
|
610
|
+
if cell == 0:
|
|
611
|
+
row_str += " ."
|
|
612
|
+
elif cell == 1:
|
|
613
|
+
row_str += " #"
|
|
614
|
+
elif cell == 2:
|
|
615
|
+
row_str += " $"
|
|
616
|
+
elif cell == 3:
|
|
617
|
+
row_str += " ○"
|
|
618
|
+
elif cell == 4:
|
|
619
|
+
row_str += " @"
|
|
620
|
+
elif cell == 5:
|
|
621
|
+
row_str += " ☒"
|
|
622
|
+
elif cell == 6:
|
|
623
|
+
row_str += " Θ"
|
|
624
|
+
else:
|
|
625
|
+
row_str += " ?"
|
|
626
|
+
lines.append(row_str)
|
|
627
|
+
|
|
628
|
+
lines.append("\nLegend: @ = player, $ = box, ○ = goal, # = wall")
|
|
629
|
+
lines.append(" ☒ = box on goal, Θ = player on goal")
|
|
630
|
+
|
|
631
|
+
return "\n".join(lines)
|
|
632
|
+
|
|
633
|
+
def get_rules(self) -> str:
|
|
634
|
+
"""Get the rules description for Sokoban.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
Multi-line string describing the puzzle rules
|
|
638
|
+
"""
|
|
639
|
+
return """SOKOBAN RULES:
|
|
640
|
+
- Move the player (@) to push boxes ($) onto goals (○)
|
|
641
|
+
- You can only push boxes, not pull them
|
|
642
|
+
- You cannot push a box into a wall or another box
|
|
643
|
+
- Goal: Get all boxes onto goal positions
|
|
644
|
+
- Moves are irreversible - plan carefully!"""
|
|
645
|
+
|
|
646
|
+
def get_commands(self) -> str:
|
|
647
|
+
"""Get the available commands for Sokoban.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
Multi-line string describing available commands
|
|
651
|
+
"""
|
|
652
|
+
return """SOKOBAN COMMANDS:
|
|
653
|
+
up (or u) - Move player up
|
|
654
|
+
down (or d) - Move player down
|
|
655
|
+
left (or l) - Move player left
|
|
656
|
+
right (or r) - Move player right
|
|
657
|
+
show - Display the current grid
|
|
658
|
+
hint - Get a hint
|
|
659
|
+
check - Check if puzzle is solved
|
|
660
|
+
reset - Reset to initial state
|
|
661
|
+
menu - Return to game selection
|
|
662
|
+
quit - Exit the server"""
|
|
663
|
+
|
|
664
|
+
def get_stats(self) -> str:
|
|
665
|
+
"""Get current game statistics.
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
String with game stats
|
|
669
|
+
"""
|
|
670
|
+
boxes_on_goals = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 5)
|
|
671
|
+
return f"Moves made: {self.moves_made} | Boxes on goals: {boxes_on_goals}/{self.num_boxes} | Seed: {self.seed}"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Configuration for Star Battle game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StarBattleConfig(BaseModel):
|
|
9
|
+
"""Configuration for Star Battle game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
size: int = Field(ge=6, le=10, description="Grid size (NxN)")
|
|
13
|
+
stars_per_row: int = Field(ge=1, le=2, description="Number of stars per row/column/region")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "StarBattleConfig":
|
|
17
|
+
"""Create config from difficulty level."""
|
|
18
|
+
config_map = {
|
|
19
|
+
DifficultyLevel.EASY: {"size": 6, "stars_per_row": 1},
|
|
20
|
+
DifficultyLevel.MEDIUM: {"size": 8, "stars_per_row": 2},
|
|
21
|
+
DifficultyLevel.HARD: {"size": 10, "stars_per_row": 2},
|
|
22
|
+
}
|
|
23
|
+
params = config_map[difficulty]
|
|
24
|
+
return cls(difficulty=difficulty, **params)
|