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.
Files changed (112) hide show
  1. chuk_puzzles_gym/__init__.py +19 -0
  2. chuk_puzzles_gym/constants.py +9 -0
  3. chuk_puzzles_gym/eval.py +763 -0
  4. chuk_puzzles_gym/export/__init__.py +20 -0
  5. chuk_puzzles_gym/export/dataset.py +376 -0
  6. chuk_puzzles_gym/games/__init__.py +94 -0
  7. chuk_puzzles_gym/games/_base/__init__.py +6 -0
  8. chuk_puzzles_gym/games/_base/commands.py +91 -0
  9. chuk_puzzles_gym/games/_base/game.py +337 -0
  10. chuk_puzzles_gym/games/binary/__init__.py +6 -0
  11. chuk_puzzles_gym/games/binary/config.py +23 -0
  12. chuk_puzzles_gym/games/binary/game.py +434 -0
  13. chuk_puzzles_gym/games/bridges/__init__.py +6 -0
  14. chuk_puzzles_gym/games/bridges/config.py +24 -0
  15. chuk_puzzles_gym/games/bridges/game.py +489 -0
  16. chuk_puzzles_gym/games/einstein/__init__.py +6 -0
  17. chuk_puzzles_gym/games/einstein/config.py +23 -0
  18. chuk_puzzles_gym/games/einstein/constants.py +13 -0
  19. chuk_puzzles_gym/games/einstein/game.py +366 -0
  20. chuk_puzzles_gym/games/einstein/models.py +35 -0
  21. chuk_puzzles_gym/games/fillomino/__init__.py +6 -0
  22. chuk_puzzles_gym/games/fillomino/config.py +24 -0
  23. chuk_puzzles_gym/games/fillomino/game.py +516 -0
  24. chuk_puzzles_gym/games/futoshiki/__init__.py +6 -0
  25. chuk_puzzles_gym/games/futoshiki/config.py +23 -0
  26. chuk_puzzles_gym/games/futoshiki/game.py +391 -0
  27. chuk_puzzles_gym/games/hidato/__init__.py +6 -0
  28. chuk_puzzles_gym/games/hidato/config.py +24 -0
  29. chuk_puzzles_gym/games/hidato/game.py +403 -0
  30. chuk_puzzles_gym/games/hitori/__init__.py +6 -0
  31. chuk_puzzles_gym/games/hitori/config.py +23 -0
  32. chuk_puzzles_gym/games/hitori/game.py +451 -0
  33. chuk_puzzles_gym/games/kakuro/__init__.py +6 -0
  34. chuk_puzzles_gym/games/kakuro/config.py +24 -0
  35. chuk_puzzles_gym/games/kakuro/game.py +399 -0
  36. chuk_puzzles_gym/games/kenken/__init__.py +6 -0
  37. chuk_puzzles_gym/games/kenken/config.py +24 -0
  38. chuk_puzzles_gym/games/kenken/enums.py +13 -0
  39. chuk_puzzles_gym/games/kenken/game.py +486 -0
  40. chuk_puzzles_gym/games/kenken/models.py +15 -0
  41. chuk_puzzles_gym/games/killer_sudoku/__init__.py +6 -0
  42. chuk_puzzles_gym/games/killer_sudoku/config.py +23 -0
  43. chuk_puzzles_gym/games/killer_sudoku/game.py +502 -0
  44. chuk_puzzles_gym/games/killer_sudoku/models.py +15 -0
  45. chuk_puzzles_gym/games/knapsack/__init__.py +6 -0
  46. chuk_puzzles_gym/games/knapsack/config.py +24 -0
  47. chuk_puzzles_gym/games/knapsack/enums.py +10 -0
  48. chuk_puzzles_gym/games/knapsack/game.py +340 -0
  49. chuk_puzzles_gym/games/knapsack/models.py +13 -0
  50. chuk_puzzles_gym/games/lights_out/__init__.py +6 -0
  51. chuk_puzzles_gym/games/lights_out/config.py +24 -0
  52. chuk_puzzles_gym/games/lights_out/game.py +249 -0
  53. chuk_puzzles_gym/games/logic_grid/__init__.py +6 -0
  54. chuk_puzzles_gym/games/logic_grid/config.py +24 -0
  55. chuk_puzzles_gym/games/logic_grid/constants.py +12 -0
  56. chuk_puzzles_gym/games/logic_grid/game.py +333 -0
  57. chuk_puzzles_gym/games/logic_grid/models.py +24 -0
  58. chuk_puzzles_gym/games/mastermind/__init__.py +6 -0
  59. chuk_puzzles_gym/games/mastermind/config.py +25 -0
  60. chuk_puzzles_gym/games/mastermind/game.py +297 -0
  61. chuk_puzzles_gym/games/minesweeper/__init__.py +6 -0
  62. chuk_puzzles_gym/games/minesweeper/config.py +24 -0
  63. chuk_puzzles_gym/games/minesweeper/enums.py +12 -0
  64. chuk_puzzles_gym/games/minesweeper/game.py +432 -0
  65. chuk_puzzles_gym/games/nonogram/__init__.py +6 -0
  66. chuk_puzzles_gym/games/nonogram/config.py +23 -0
  67. chuk_puzzles_gym/games/nonogram/game.py +296 -0
  68. chuk_puzzles_gym/games/nurikabe/__init__.py +6 -0
  69. chuk_puzzles_gym/games/nurikabe/config.py +24 -0
  70. chuk_puzzles_gym/games/nurikabe/enums.py +14 -0
  71. chuk_puzzles_gym/games/nurikabe/game.py +586 -0
  72. chuk_puzzles_gym/games/scheduler/__init__.py +6 -0
  73. chuk_puzzles_gym/games/scheduler/config.py +25 -0
  74. chuk_puzzles_gym/games/scheduler/constants.py +15 -0
  75. chuk_puzzles_gym/games/scheduler/enums.py +10 -0
  76. chuk_puzzles_gym/games/scheduler/game.py +431 -0
  77. chuk_puzzles_gym/games/scheduler/models.py +14 -0
  78. chuk_puzzles_gym/games/shikaku/__init__.py +6 -0
  79. chuk_puzzles_gym/games/shikaku/config.py +24 -0
  80. chuk_puzzles_gym/games/shikaku/game.py +419 -0
  81. chuk_puzzles_gym/games/slitherlink/__init__.py +6 -0
  82. chuk_puzzles_gym/games/slitherlink/config.py +23 -0
  83. chuk_puzzles_gym/games/slitherlink/game.py +386 -0
  84. chuk_puzzles_gym/games/sokoban/__init__.py +6 -0
  85. chuk_puzzles_gym/games/sokoban/config.py +24 -0
  86. chuk_puzzles_gym/games/sokoban/game.py +671 -0
  87. chuk_puzzles_gym/games/star_battle/__init__.py +6 -0
  88. chuk_puzzles_gym/games/star_battle/config.py +24 -0
  89. chuk_puzzles_gym/games/star_battle/game.py +390 -0
  90. chuk_puzzles_gym/games/sudoku/__init__.py +7 -0
  91. chuk_puzzles_gym/games/sudoku/commands.py +96 -0
  92. chuk_puzzles_gym/games/sudoku/config.py +22 -0
  93. chuk_puzzles_gym/games/sudoku/game.py +328 -0
  94. chuk_puzzles_gym/games/tents/__init__.py +6 -0
  95. chuk_puzzles_gym/games/tents/config.py +24 -0
  96. chuk_puzzles_gym/games/tents/game.py +416 -0
  97. chuk_puzzles_gym/gym_env.py +465 -0
  98. chuk_puzzles_gym/models/__init__.py +47 -0
  99. chuk_puzzles_gym/models/base.py +30 -0
  100. chuk_puzzles_gym/models/config.py +11 -0
  101. chuk_puzzles_gym/models/enums.py +104 -0
  102. chuk_puzzles_gym/models/evaluation.py +487 -0
  103. chuk_puzzles_gym/models/games.py +12 -0
  104. chuk_puzzles_gym/server.py +1171 -0
  105. chuk_puzzles_gym/trace/__init__.py +10 -0
  106. chuk_puzzles_gym/trace/generator.py +726 -0
  107. chuk_puzzles_gym/utils/__init__.py +4 -0
  108. chuk_puzzles_gym-0.9.dist-info/METADATA +1471 -0
  109. chuk_puzzles_gym-0.9.dist-info/RECORD +112 -0
  110. chuk_puzzles_gym-0.9.dist-info/WHEEL +5 -0
  111. chuk_puzzles_gym-0.9.dist-info/entry_points.txt +4 -0
  112. 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,6 @@
1
+ """StarBattle puzzle game module."""
2
+
3
+ from .config import StarBattleConfig
4
+ from .game import StarBattleGame
5
+
6
+ __all__ = ["StarBattleGame", "StarBattleConfig"]
@@ -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)