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,386 @@
1
+ """Slitherlink puzzle game implementation."""
2
+
3
+ from typing import Any
4
+
5
+ from ...models import DifficultyLevel, DifficultyProfile, MoveResult
6
+ from .._base import PuzzleGame
7
+ from .config import SlitherlinkConfig
8
+
9
+
10
+ class SlitherlinkGame(PuzzleGame):
11
+ """Slitherlink puzzle game.
12
+
13
+ Draw a single continuous loop by connecting dots.
14
+ Numbers indicate how many edges around that cell are part of the loop.
15
+ The loop must not branch or cross itself.
16
+ """
17
+
18
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
19
+ """Initialize a new Slitherlink game.
20
+
21
+ Args:
22
+ difficulty: Game difficulty level (easy/medium/hard)
23
+ """
24
+ super().__init__(difficulty, seed, **kwargs)
25
+
26
+ # Grid configuration
27
+ self.config = SlitherlinkConfig.from_difficulty(self.difficulty)
28
+ self.size = self.config.size
29
+
30
+ # Clue grid: -1 = no clue, 0-3 = number of edges
31
+ self.clues = [[-1 for _ in range(self.size)] for _ in range(self.size)]
32
+
33
+ # Edge states: 0 = unknown, 1 = line, 2 = no line (X)
34
+ # Horizontal edges: (size+1) rows x size columns
35
+ self.h_edges = [[0 for _ in range(self.size)] for _ in range(self.size + 1)]
36
+ # Vertical edges: size rows x (size+1) columns
37
+ self.v_edges = [[0 for _ in range(self.size + 1)] for _ in range(self.size)]
38
+
39
+ # Solution edges
40
+ self.solution_h_edges = [[0 for _ in range(self.size)] for _ in range(self.size + 1)]
41
+ self.solution_v_edges = [[0 for _ in range(self.size + 1)] for _ in range(self.size)]
42
+
43
+ @property
44
+ def name(self) -> str:
45
+ """The display name of this puzzle type."""
46
+ return "Slitherlink"
47
+
48
+ @property
49
+ def description(self) -> str:
50
+ """A one-line description of this puzzle type."""
51
+ return "Draw a single loop - numbers show edge counts"
52
+
53
+ @property
54
+ def constraint_types(self) -> list[str]:
55
+ """Constraint types demonstrated by this puzzle."""
56
+ return ["global_loop", "local_counting", "topological", "uniqueness"]
57
+
58
+ @property
59
+ def business_analogies(self) -> list[str]:
60
+ """Business problems this puzzle models."""
61
+ return ["circuit_design", "routing", "loop_detection"]
62
+
63
+ @property
64
+ def complexity_profile(self) -> dict[str, str]:
65
+ """Complexity profile of this puzzle."""
66
+ return {"reasoning_type": "deductive", "search_space": "exponential", "constraint_density": "moderate"}
67
+
68
+ @property
69
+ def optimal_steps(self) -> int | None:
70
+ """Minimum steps = line segments to draw."""
71
+ if not hasattr(self, "solution_h_edges") or not hasattr(self, "solution_v_edges"):
72
+ return None
73
+ h_lines = sum(sum(row) for row in self.solution_h_edges)
74
+ v_lines = sum(sum(row) for row in self.solution_v_edges)
75
+ return h_lines + v_lines
76
+
77
+ @property
78
+ def difficulty_profile(self) -> "DifficultyProfile":
79
+ """Difficulty characteristics for Slitherlink."""
80
+
81
+ logic_depth = {
82
+ DifficultyLevel.EASY.value: 3,
83
+ DifficultyLevel.MEDIUM.value: 5,
84
+ DifficultyLevel.HARD.value: 7,
85
+ }.get(self.difficulty.value, 4)
86
+ return DifficultyProfile(
87
+ logic_depth=logic_depth,
88
+ branching_factor=4.0, # 4 edges per cell
89
+ state_observability=1.0,
90
+ constraint_density=0.6,
91
+ )
92
+
93
+ def _generate_simple_loop(self) -> None:
94
+ """Generate a simple rectangular loop as the solution."""
95
+ # Create a simple rectangular loop for testing
96
+ # This is a simplified generator - a full implementation would
97
+ # use more sophisticated loop generation algorithms
98
+
99
+ # For a simple version, create a border loop
100
+ for col in range(self.size):
101
+ self.solution_h_edges[0][col] = 1 # Top edge
102
+ self.solution_h_edges[self.size][col] = 1 # Bottom edge
103
+
104
+ for row in range(self.size):
105
+ self.solution_v_edges[row][0] = 1 # Left edge
106
+ self.solution_v_edges[row][self.size] = 1 # Right edge
107
+
108
+ def _count_edges_around_cell(self, row: int, col: int, h_edges: list[list[int]], v_edges: list[list[int]]) -> int:
109
+ """Count edges around a cell in the given edge configuration.
110
+
111
+ Args:
112
+ row: Cell row
113
+ col: Cell column
114
+ h_edges: Horizontal edges grid
115
+ v_edges: Vertical edges grid
116
+
117
+ Returns:
118
+ Number of edges around the cell (0-4)
119
+ """
120
+ count = 0
121
+ # Top edge
122
+ if h_edges[row][col] == 1:
123
+ count += 1
124
+ # Bottom edge
125
+ if h_edges[row + 1][col] == 1:
126
+ count += 1
127
+ # Left edge
128
+ if v_edges[row][col] == 1:
129
+ count += 1
130
+ # Right edge
131
+ if v_edges[row][col + 1] == 1:
132
+ count += 1
133
+ return count
134
+
135
+ async def generate_puzzle(self) -> None:
136
+ """Generate a new Slitherlink puzzle."""
137
+ # Generate solution loop
138
+ self._generate_simple_loop()
139
+
140
+ # Generate clues based on solution
141
+ # Place clues based on difficulty
142
+ num_clues_map = {
143
+ DifficultyLevel.EASY: self.size * 2,
144
+ DifficultyLevel.MEDIUM: self.size * 3,
145
+ DifficultyLevel.HARD: self.size * 4,
146
+ }
147
+ num_clues = num_clues_map[self.difficulty]
148
+
149
+ placed = 0
150
+ attempts = 0
151
+ max_attempts = num_clues * 10
152
+
153
+ while placed < num_clues and attempts < max_attempts:
154
+ row = self._rng.randint(0, self.size - 1)
155
+ col = self._rng.randint(0, self.size - 1)
156
+
157
+ if self.clues[row][col] == -1: # No clue yet
158
+ edge_count = self._count_edges_around_cell(row, col, self.solution_h_edges, self.solution_v_edges)
159
+ # Place clue
160
+ self.clues[row][col] = edge_count
161
+ placed += 1
162
+
163
+ attempts += 1
164
+
165
+ # Reset player edges
166
+ self.h_edges = [[0 for _ in range(self.size)] for _ in range(self.size + 1)]
167
+ self.v_edges = [[0 for _ in range(self.size + 1)] for _ in range(self.size)]
168
+
169
+ self.moves_made = 0
170
+ self.game_started = True
171
+
172
+ async def validate_move(self, edge_type: str, row: int, col: int, state: int) -> MoveResult:
173
+ """Set an edge state.
174
+
175
+ Args:
176
+ edge_type: 'h' for horizontal, 'v' for vertical
177
+ row: Row index (1-indexed, user-facing)
178
+ col: Column index (1-indexed, user-facing)
179
+ state: 0=unknown, 1=line, 2=no line (X)
180
+
181
+ Returns:
182
+ MoveResult with success status and message
183
+ """
184
+ # Convert to 0-indexed
185
+ row -= 1
186
+ col -= 1
187
+
188
+ # Validate state
189
+ if state not in [0, 1, 2]:
190
+ return MoveResult(success=False, message="Invalid state. Use 0=clear, 1=line, 2=X")
191
+
192
+ # Validate edge type and coordinates
193
+ if edge_type.lower() == "h":
194
+ if not (0 <= row <= self.size and 0 <= col < self.size):
195
+ return MoveResult(
196
+ success=False, message=f"Invalid horizontal edge. Row: 1-{self.size + 1}, Col: 1-{self.size}"
197
+ )
198
+ self.h_edges[row][col] = state
199
+ edge_name = "horizontal"
200
+ elif edge_type.lower() == "v":
201
+ if not (0 <= row < self.size and 0 <= col <= self.size):
202
+ return MoveResult(
203
+ success=False, message=f"Invalid vertical edge. Row: 1-{self.size}, Col: 1-{self.size + 1}"
204
+ )
205
+ self.v_edges[row][col] = state
206
+ edge_name = "vertical"
207
+ else:
208
+ return MoveResult(success=False, message="Invalid edge type. Use 'h' or 'v'")
209
+
210
+ self.moves_made += 1
211
+
212
+ state_name = {0: "cleared", 1: "set to line", 2: "marked as X"}[state]
213
+ return MoveResult(
214
+ success=True,
215
+ message=f"{edge_name.capitalize()} edge ({row + 1},{col + 1}) {state_name}",
216
+ state_changed=True,
217
+ )
218
+
219
+ def is_complete(self) -> bool:
220
+ """Check if the puzzle is complete and correct."""
221
+ # Check all clues are satisfied
222
+ for row in range(self.size):
223
+ for col in range(self.size):
224
+ if self.clues[row][col] != -1:
225
+ edge_count = self._count_edges_around_cell(row, col, self.h_edges, self.v_edges)
226
+ if edge_count != self.clues[row][col]:
227
+ return False
228
+
229
+ # Check that we have a valid loop (simplified check)
230
+ # Count total edges - should be > 0 and even
231
+ total_edges = sum(sum(1 for e in row if e == 1) for row in self.h_edges)
232
+ total_edges += sum(sum(1 for e in row if e == 1) for row in self.v_edges)
233
+
234
+ if total_edges == 0:
235
+ return False
236
+
237
+ # Check each vertex has 0 or 2 edges (no branches)
238
+ for dot_row in range(self.size + 1):
239
+ for dot_col in range(self.size + 1):
240
+ edges = 0
241
+
242
+ # Count edges connected to this dot
243
+ # Horizontal edge to the left (row=dot_row, col=dot_col-1)
244
+ if dot_col > 0:
245
+ if self.h_edges[dot_row][dot_col - 1] == 1:
246
+ edges += 1
247
+
248
+ # Horizontal edge to the right (row=dot_row, col=dot_col)
249
+ if dot_col < self.size:
250
+ if self.h_edges[dot_row][dot_col] == 1:
251
+ edges += 1
252
+
253
+ # Vertical edge above (row=dot_row-1, col=dot_col)
254
+ if dot_row > 0:
255
+ if self.v_edges[dot_row - 1][dot_col] == 1:
256
+ edges += 1
257
+
258
+ # Vertical edge below (row=dot_row, col=dot_col)
259
+ if dot_row < self.size:
260
+ if self.v_edges[dot_row][dot_col] == 1:
261
+ edges += 1
262
+
263
+ # Each vertex must have exactly 0 or 2 edges (part of loop or not)
264
+ if edges != 0 and edges != 2:
265
+ return False
266
+
267
+ return True
268
+
269
+ async def get_hint(self) -> tuple[Any, str] | None:
270
+ """Get a hint for the next move.
271
+
272
+ Returns:
273
+ Tuple of (hint_data, hint_message) or None
274
+ """
275
+ # Find an edge that's in the solution but not set by player
276
+ for row in range(self.size + 1):
277
+ for col in range(self.size):
278
+ if self.solution_h_edges[row][col] == 1 and self.h_edges[row][col] != 1:
279
+ hint_data = ("h", row + 1, col + 1, 1)
280
+ hint_message = f"Try setting horizontal edge at ({row + 1},{col + 1}) to line"
281
+ return hint_data, hint_message
282
+
283
+ for row in range(self.size):
284
+ for col in range(self.size + 1):
285
+ if self.solution_v_edges[row][col] == 1 and self.v_edges[row][col] != 1:
286
+ hint_data = ("v", row + 1, col + 1, 1)
287
+ hint_message = f"Try setting vertical edge at ({row + 1},{col + 1}) to line"
288
+ return hint_data, hint_message
289
+
290
+ return None
291
+
292
+ def render_grid(self) -> str:
293
+ """Render the current puzzle state as ASCII art.
294
+
295
+ Returns:
296
+ String representation of the puzzle grid
297
+ """
298
+ lines = []
299
+
300
+ # Render grid with dots and edges
301
+ for row in range(self.size + 1):
302
+ # Horizontal edges row
303
+ if row <= self.size:
304
+ h_line = " "
305
+ for col in range(self.size):
306
+ h_line += "+"
307
+ # Horizontal edge
308
+ if row < self.size + 1:
309
+ edge = self.h_edges[row][col]
310
+ if edge == 1:
311
+ h_line += "---"
312
+ elif edge == 2:
313
+ h_line += " X "
314
+ else:
315
+ h_line += " "
316
+ h_line += "+"
317
+ lines.append(h_line)
318
+
319
+ # Vertical edges and cells row
320
+ if row < self.size:
321
+ v_line = " "
322
+ for col in range(self.size + 1):
323
+ # Vertical edge
324
+ if col <= self.size:
325
+ edge = self.v_edges[row][col]
326
+ if edge == 1:
327
+ v_line += "|"
328
+ elif edge == 2:
329
+ v_line += "X"
330
+ else:
331
+ v_line += " "
332
+
333
+ # Cell content (clue)
334
+ if col < self.size:
335
+ clue = self.clues[row][col]
336
+ if clue == -1:
337
+ v_line += " "
338
+ else:
339
+ v_line += f" {clue} "
340
+
341
+ lines.append(v_line)
342
+
343
+ return "\n".join(lines)
344
+
345
+ def get_rules(self) -> str:
346
+ """Get the rules description for Slitherlink.
347
+
348
+ Returns:
349
+ Multi-line string describing the puzzle rules
350
+ """
351
+ return """SLITHERLINK RULES:
352
+ - Draw a single continuous loop by connecting dots
353
+ - Numbers show how many edges around that cell are part of the loop
354
+ - The loop must not branch or cross itself
355
+ - Each dot connects to exactly 0 or 2 edges
356
+ - Empty cells have no constraint on edge count"""
357
+
358
+ def get_commands(self) -> str:
359
+ """Get the available commands for Slitherlink.
360
+
361
+ Returns:
362
+ Multi-line string describing available commands
363
+ """
364
+ return """SLITHERLINK COMMANDS:
365
+ set h <row> <col> <state> - Set horizontal edge (e.g., 'set h 1 2 1')
366
+ set v <row> <col> <state> - Set vertical edge (e.g., 'set v 2 1 1')
367
+ state: 0=clear, 1=line, 2=X (not part of loop)
368
+ show - Display current grid
369
+ hint - Get a hint for the next move
370
+ check - Check if puzzle is complete
371
+ solve - Show solution (ends game)
372
+ menu - Return to game selection
373
+ quit - Exit the server"""
374
+
375
+ def get_stats(self) -> str:
376
+ """Get current game statistics.
377
+
378
+ Returns:
379
+ String with game stats
380
+ """
381
+ total_lines = sum(sum(1 for e in row if e == 1) for row in self.h_edges)
382
+ total_lines += sum(sum(1 for e in row if e == 1) for row in self.v_edges)
383
+
384
+ total_clues = sum(sum(1 for c in row if c != -1) for row in self.clues)
385
+
386
+ return f"Moves made: {self.moves_made} | Lines drawn: {total_lines} | Clues: {total_clues} | Grid: {self.size}×{self.size} | Seed: {self.seed}"
@@ -0,0 +1,6 @@
1
+ """Sokoban puzzle game module."""
2
+
3
+ from .config import SokobanConfig
4
+ from .game import SokobanGame
5
+
6
+ __all__ = ["SokobanGame", "SokobanConfig"]
@@ -0,0 +1,24 @@
1
+ """Configuration for Sokoban game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class SokobanConfig(BaseModel):
9
+ """Configuration for Sokoban 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
+ num_boxes: int = Field(ge=2, le=6, description="Number of boxes to push")
14
+
15
+ @classmethod
16
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "SokobanConfig":
17
+ """Create config from difficulty level."""
18
+ config_map = {
19
+ DifficultyLevel.EASY: {"size": 6, "num_boxes": 2},
20
+ DifficultyLevel.MEDIUM: {"size": 8, "num_boxes": 3},
21
+ DifficultyLevel.HARD: {"size": 10, "num_boxes": 4},
22
+ }
23
+ params = config_map[difficulty]
24
+ return cls(difficulty=difficulty, **params)