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,391 @@
1
+ """Futoshiki 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 FutoshikiConfig
8
+
9
+
10
+ class FutoshikiGame(PuzzleGame):
11
+ """Futoshiki (inequality constraints) puzzle game.
12
+
13
+ Similar to Sudoku but uses inequality constraints between adjacent cells.
14
+ Each row and column must contain unique numbers from 1 to N.
15
+ """
16
+
17
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
18
+ """Initialize a new Futoshiki game.
19
+
20
+ Args:
21
+ difficulty: Game difficulty level (easy=4x4, medium=5x5, hard=6x6)
22
+ """
23
+ super().__init__(difficulty, seed, **kwargs)
24
+
25
+ # Use pydantic config based on difficulty
26
+ self.config = FutoshikiConfig.from_difficulty(self.difficulty)
27
+ self.size = self.config.size
28
+
29
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
30
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
31
+ self.initial_grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
32
+
33
+ # Inequalities: list of ((row1, col1), (row2, col2))
34
+ # Meaning: cell1 > cell2
35
+ self.inequalities: list[tuple[tuple[int, int], tuple[int, int]]] = []
36
+
37
+ @property
38
+ def name(self) -> str:
39
+ """The display name of this puzzle type."""
40
+ return "Futoshiki"
41
+
42
+ @property
43
+ def description(self) -> str:
44
+ """A one-line description of this puzzle type."""
45
+ return "Inequality number puzzle - fill grid with constraints"
46
+
47
+ @property
48
+ def constraint_types(self) -> list[str]:
49
+ """Constraint types demonstrated by this puzzle."""
50
+ return ["all_different", "linear_inequality", "ordering", "comparison_constraints"]
51
+
52
+ @property
53
+ def business_analogies(self) -> list[str]:
54
+ """Business problems this puzzle models."""
55
+ return ["priority_ranking", "ordering_with_constraints", "relative_positioning", "inequality_systems"]
56
+
57
+ @property
58
+ def complexity_profile(self) -> dict[str, str]:
59
+ """Complexity profile of this puzzle."""
60
+ return {"reasoning_type": "deductive", "search_space": "medium", "constraint_density": "moderate"}
61
+
62
+ @property
63
+ def optimal_steps(self) -> int | None:
64
+ """Minimum steps = empty cells to fill."""
65
+ return sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
66
+
67
+ @property
68
+ def difficulty_profile(self) -> "DifficultyProfile":
69
+ """Difficulty characteristics for Futoshiki."""
70
+
71
+ empty = self.optimal_steps or 0
72
+ total = self.size * self.size
73
+ logic_depth = {
74
+ DifficultyLevel.EASY.value: 2,
75
+ DifficultyLevel.MEDIUM.value: 3,
76
+ DifficultyLevel.HARD.value: 5,
77
+ }.get(self.difficulty.value, 3)
78
+ branching = 2.0 + (empty / total) * 2
79
+ density = 1.0 - (empty / total) if total > 0 else 0.5
80
+ return DifficultyProfile(
81
+ logic_depth=logic_depth,
82
+ branching_factor=round(branching, 1),
83
+ state_observability=1.0,
84
+ constraint_density=round(density, 2),
85
+ )
86
+
87
+ def is_valid_move(self, row: int, col: int, num: int, grid: list[list[int]] | None = None) -> bool:
88
+ """Check if placing num at (row, col) is valid.
89
+
90
+ Args:
91
+ row: Row index (0-indexed)
92
+ col: Column index (0-indexed)
93
+ num: Number to place (1 to self.size)
94
+ grid: Grid to check against (defaults to self.grid)
95
+
96
+ Returns:
97
+ True if the move is valid, False otherwise
98
+ """
99
+ if grid is None:
100
+ grid = self.grid
101
+
102
+ # Check row uniqueness
103
+ for c in range(self.size):
104
+ if c != col and grid[row][c] == num:
105
+ return False
106
+
107
+ # Check column uniqueness
108
+ for r in range(self.size):
109
+ if r != row and grid[r][col] == num:
110
+ return False
111
+
112
+ # Check inequality constraints involving this cell
113
+ for (r1, c1), (r2, c2) in self.inequalities:
114
+ # Check if this cell is involved
115
+ if (r1, c1) == (row, col):
116
+ # This cell should be > cell2
117
+ if grid[r2][c2] != 0 and num <= grid[r2][c2]:
118
+ return False
119
+ elif (r2, c2) == (row, col):
120
+ # Cell1 should be > this cell
121
+ if grid[r1][c1] != 0 and grid[r1][c1] <= num:
122
+ return False
123
+
124
+ return True
125
+
126
+ def solve(self, grid: list[list[int]]) -> bool:
127
+ """Solve the Futoshiki puzzle using backtracking.
128
+
129
+ Args:
130
+ grid: The Futoshiki grid to solve
131
+
132
+ Returns:
133
+ True if solved, False otherwise
134
+ """
135
+ for row in range(self.size):
136
+ for col in range(self.size):
137
+ if grid[row][col] == 0:
138
+ for num in range(1, self.size + 1):
139
+ grid[row][col] = num
140
+
141
+ if self.is_valid_move(row, col, num, grid) and self.solve(grid):
142
+ return True
143
+
144
+ grid[row][col] = 0
145
+
146
+ return False
147
+ return True
148
+
149
+ def _generate_inequalities(self) -> None:
150
+ """Generate inequality constraints from the solution."""
151
+ self.inequalities = []
152
+
153
+ # Determine number of inequalities based on difficulty
154
+ num_inequalities_map = {
155
+ DifficultyLevel.EASY: self.size * 2,
156
+ DifficultyLevel.MEDIUM: self.size * 3,
157
+ DifficultyLevel.HARD: self.size * 4,
158
+ }
159
+ num_inequalities = num_inequalities_map[self.difficulty]
160
+
161
+ # Collect all possible adjacent pairs
162
+ possible_pairs = []
163
+
164
+ # Horizontal pairs
165
+ for row in range(self.size):
166
+ for col in range(self.size - 1):
167
+ possible_pairs.append(((row, col), (row, col + 1)))
168
+
169
+ # Vertical pairs
170
+ for row in range(self.size - 1):
171
+ for col in range(self.size):
172
+ possible_pairs.append(((row, col), (row + 1, col)))
173
+
174
+ # Randomly select inequalities
175
+ self._rng.shuffle(possible_pairs)
176
+
177
+ for (r1, c1), (r2, c2) in possible_pairs[:num_inequalities]:
178
+ val1 = self.solution[r1][c1]
179
+ val2 = self.solution[r2][c2]
180
+
181
+ if val1 > val2:
182
+ self.inequalities.append(((r1, c1), (r2, c2)))
183
+ else:
184
+ self.inequalities.append(((r2, c2), (r1, c1)))
185
+
186
+ async def generate_puzzle(self) -> None:
187
+ """Generate a new Futoshiki puzzle."""
188
+ # Generate a valid Latin square as solution
189
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
190
+
191
+ # Simple solution generation: shifted rows
192
+ for row in range(self.size):
193
+ for col in range(self.size):
194
+ self.grid[row][col] = (row + col) % self.size + 1
195
+
196
+ # Shuffle rows and columns to make it more random
197
+ row_order = list(range(self.size))
198
+ col_order = list(range(self.size))
199
+ self._rng.shuffle(row_order)
200
+ self._rng.shuffle(col_order)
201
+
202
+ shuffled = [[self.grid[row_order[r]][col_order[c]] for c in range(self.size)] for r in range(self.size)]
203
+ self.solution = shuffled
204
+
205
+ # Generate inequalities
206
+ self._generate_inequalities()
207
+
208
+ # Remove some cells based on difficulty
209
+ cells_to_remove_map = {
210
+ DifficultyLevel.EASY: self.size * 2,
211
+ DifficultyLevel.MEDIUM: self.size * 3,
212
+ DifficultyLevel.HARD: self.size * 4,
213
+ }
214
+ cells_to_remove = cells_to_remove_map[self.difficulty]
215
+
216
+ # Copy solution to grid
217
+ self.grid = [row[:] for row in self.solution]
218
+
219
+ # Randomly remove cells
220
+ cells = [(r, c) for r in range(self.size) for c in range(self.size)]
221
+ self._rng.shuffle(cells)
222
+
223
+ for r, c in cells[:cells_to_remove]:
224
+ self.grid[r][c] = 0
225
+
226
+ self.initial_grid = [row[:] for row in self.grid]
227
+ self.moves_made = 0
228
+ self.game_started = True
229
+
230
+ async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
231
+ """Place a number on the grid.
232
+
233
+ Args:
234
+ row: Row index (1-indexed, user-facing)
235
+ col: Column index (1-indexed, user-facing)
236
+ num: Number to place (1 to self.size, or 0 to clear)
237
+
238
+ Returns:
239
+ MoveResult with success status and message
240
+ """
241
+ # Convert to 0-indexed
242
+ row -= 1
243
+ col -= 1
244
+
245
+ # Validate coordinates
246
+ if not (0 <= row < self.size and 0 <= col < self.size):
247
+ return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
248
+
249
+ # Check if this cell is part of the initial puzzle
250
+ if self.initial_grid[row][col] != 0:
251
+ return MoveResult(success=False, message="Cannot modify initial puzzle cells.")
252
+
253
+ # Clear the cell
254
+ if num == 0:
255
+ self.grid[row][col] = 0
256
+ return MoveResult(success=True, message="Cell cleared.", state_changed=True)
257
+
258
+ # Validate number
259
+ if not (1 <= num <= self.size):
260
+ return MoveResult(success=False, message=f"Invalid number. Use 1-{self.size} or 0 to clear.")
261
+
262
+ # Check if the move is valid
263
+ old_value = self.grid[row][col]
264
+ self.grid[row][col] = num
265
+
266
+ if not self.is_valid_move(row, col, num):
267
+ self.grid[row][col] = old_value
268
+ return MoveResult(
269
+ success=False, message="Invalid move! This violates uniqueness or inequality constraints."
270
+ )
271
+
272
+ self.moves_made += 1
273
+ return MoveResult(success=True, message="Number placed successfully!", state_changed=True)
274
+
275
+ def is_complete(self) -> bool:
276
+ """Check if the puzzle is complete and correct."""
277
+ for row in range(self.size):
278
+ for col in range(self.size):
279
+ if self.grid[row][col] == 0:
280
+ return False
281
+ if self.grid[row][col] != self.solution[row][col]:
282
+ return False
283
+ return True
284
+
285
+ async def get_hint(self) -> tuple[Any, str] | None:
286
+ """Get a hint for the next move.
287
+
288
+ Returns:
289
+ Tuple of (hint_data, hint_message) or None if puzzle is complete
290
+ """
291
+ empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0]
292
+ if not empty_cells:
293
+ return None
294
+
295
+ row, col = self._rng.choice(empty_cells)
296
+ hint_data = (row + 1, col + 1, self.solution[row][col])
297
+ hint_message = f"Try placing {self.solution[row][col]} at row {row + 1}, column {col + 1}"
298
+ return hint_data, hint_message
299
+
300
+ def render_grid(self) -> str:
301
+ """Render the current puzzle state as ASCII art.
302
+
303
+ Returns:
304
+ String representation of the puzzle grid with inequalities
305
+ """
306
+ lines = []
307
+
308
+ # Build a map of horizontal and vertical inequalities
309
+ h_ineq = {} # (row, col) -> '>' or '<' between col and col+1
310
+ v_ineq = {} # (row, col) -> '^' or 'v' between row and row+1
311
+
312
+ for (r1, c1), (r2, c2) in self.inequalities:
313
+ if r1 == r2: # Horizontal
314
+ if c1 < c2:
315
+ h_ineq[(r1, c1)] = ">"
316
+ else:
317
+ h_ineq[(r1, c2)] = "<"
318
+ else: # Vertical
319
+ if r1 < r2:
320
+ v_ineq[(r1, c1)] = "v"
321
+ else:
322
+ v_ineq[(r2, c1)] = "^"
323
+
324
+ # Header - align with row format "N | ..."
325
+ header = " | " + " ".join(str(i + 1) for i in range(self.size)) + " |"
326
+ lines.append(header)
327
+
328
+ for row in range(self.size):
329
+ # Main row
330
+ line = f"{row + 1} | "
331
+ for col in range(self.size):
332
+ cell = self.grid[row][col]
333
+ cell_str = str(cell) if cell != 0 else "."
334
+ line += cell_str
335
+
336
+ # Add horizontal inequality
337
+ if col < self.size - 1:
338
+ ineq = h_ineq.get((row, col), " ")
339
+ line += f" {ineq} "
340
+
341
+ line += " |"
342
+ lines.append(line)
343
+
344
+ # Vertical inequality row
345
+ if row < self.size - 1:
346
+ line = " | "
347
+ for col in range(self.size):
348
+ ineq = v_ineq.get((row, col), " ")
349
+ line += ineq
350
+ if col < self.size - 1:
351
+ line += " "
352
+ line += " |"
353
+ lines.append(line)
354
+
355
+ return "\n".join(lines)
356
+
357
+ def get_rules(self) -> str:
358
+ """Get the rules description for Futoshiki.
359
+
360
+ Returns:
361
+ Multi-line string describing the puzzle rules
362
+ """
363
+ return f"""FUTOSHIKI RULES:
364
+ - Fill {self.size}x{self.size} grid with 1-{self.size}
365
+ - No repeats in rows or columns
366
+ - Satisfy inequality signs (>, <, ^, v)"""
367
+
368
+ def get_commands(self) -> str:
369
+ """Get the available commands for Futoshiki.
370
+
371
+ Returns:
372
+ Multi-line string describing available commands
373
+ """
374
+ return """FUTOSHIKI COMMANDS:
375
+ place <row> <col> <num> - Place a number (e.g., 'place 1 2 4')
376
+ clear <row> <col> - Clear a cell
377
+ show - Display the current grid
378
+ hint - Get a hint for the next move
379
+ check - Check your progress
380
+ solve - Show the solution (ends game)
381
+ menu - Return to game selection
382
+ quit - Exit the server"""
383
+
384
+ def get_stats(self) -> str:
385
+ """Get current game statistics.
386
+
387
+ Returns:
388
+ String with game stats
389
+ """
390
+ empty = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
391
+ return f"Moves made: {self.moves_made} | Empty cells: {empty} | Inequalities: {len(self.inequalities)} | Seed: {self.seed}"
@@ -0,0 +1,6 @@
1
+ """Hidato puzzle game module."""
2
+
3
+ from .config import HidatoConfig
4
+ from .game import HidatoGame
5
+
6
+ __all__ = ["HidatoGame", "HidatoConfig"]
@@ -0,0 +1,24 @@
1
+ """Configuration for Hidato game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class HidatoConfig(BaseModel):
9
+ """Configuration for Hidato game."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
12
+ size: int = Field(ge=5, le=9, description="Grid size (NxN)")
13
+ num_clues: int = Field(ge=2, description="Number of clue numbers to reveal")
14
+
15
+ @classmethod
16
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "HidatoConfig":
17
+ """Create config from difficulty level."""
18
+ config_map = {
19
+ DifficultyLevel.EASY: {"size": 5, "num_clues": 8},
20
+ DifficultyLevel.MEDIUM: {"size": 7, "num_clues": 12},
21
+ DifficultyLevel.HARD: {"size": 9, "num_clues": 15},
22
+ }
23
+ params = config_map[difficulty]
24
+ return cls(difficulty=difficulty, **params)