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,403 @@
1
+ """Hidato (Number Snake) 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 HidatoConfig
8
+
9
+
10
+ class HidatoGame(PuzzleGame):
11
+ """Hidato (Number Snake) puzzle game.
12
+
13
+ Fill the grid with consecutive numbers (1 to N) such that each number
14
+ is adjacent (horizontally, vertically, or diagonally) to the next number.
15
+ Creates a continuous path through all cells.
16
+ """
17
+
18
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
19
+ """Initialize a new Hidato game.
20
+
21
+ Args:
22
+ difficulty: Game difficulty level (easy=5x5, medium=7x7, hard=9x9)
23
+ """
24
+ super().__init__(difficulty, seed, **kwargs)
25
+
26
+ # Use pydantic config based on difficulty
27
+ self.config = HidatoConfig.from_difficulty(self.difficulty)
28
+ self.size = self.config.size
29
+
30
+ # Grid: 0 = empty, 1-N = numbers
31
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
32
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
33
+ self.initial_grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
34
+
35
+ # Total numbers to place
36
+ self.total_numbers = self.size * self.size
37
+
38
+ @property
39
+ def name(self) -> str:
40
+ """The display name of this puzzle type."""
41
+ return "Hidato"
42
+
43
+ @property
44
+ def description(self) -> str:
45
+ """A one-line description of this puzzle type."""
46
+ return "Number snake puzzle - connect consecutive numbers via adjacent cells"
47
+
48
+ @property
49
+ def constraint_types(self) -> list[str]:
50
+ """Constraint types demonstrated by this puzzle."""
51
+ return ["sequential_adjacency", "hamiltonian_path", "all_different", "connectivity"]
52
+
53
+ @property
54
+ def business_analogies(self) -> list[str]:
55
+ """Business problems this puzzle models."""
56
+ return ["route_optimization", "sequential_process_flow", "path_finding", "order_fulfillment"]
57
+
58
+ @property
59
+ def complexity_profile(self) -> dict[str, str]:
60
+ """Complexity profile of this puzzle."""
61
+ return {"reasoning_type": "deductive", "search_space": "large", "constraint_density": "dense"}
62
+
63
+ @property
64
+ def optimal_steps(self) -> int | None:
65
+ """Minimum steps = empty cells to fill."""
66
+ return sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
67
+
68
+ @property
69
+ def difficulty_profile(self) -> "DifficultyProfile":
70
+ """Difficulty characteristics for Hidato."""
71
+ from ...models import DifficultyLevel
72
+
73
+ empty = self.optimal_steps or 0
74
+ total = self.size * self.size
75
+ logic_depth = {
76
+ DifficultyLevel.EASY.value: 2,
77
+ DifficultyLevel.MEDIUM.value: 4,
78
+ DifficultyLevel.HARD.value: 5,
79
+ }.get(self.difficulty.value, 3)
80
+ branching = 4.0 + (empty / total) * 4 # Up to 8 neighbors
81
+ density = 1.0 - (empty / total) if total > 0 else 0.5
82
+ return DifficultyProfile(
83
+ logic_depth=logic_depth,
84
+ branching_factor=round(branching, 1),
85
+ state_observability=1.0,
86
+ constraint_density=round(density, 2),
87
+ )
88
+
89
+ def _get_neighbors(self, row: int, col: int) -> list[tuple[int, int]]:
90
+ """Get all adjacent cells (including diagonals).
91
+
92
+ Args:
93
+ row: Row index
94
+ col: Column index
95
+
96
+ Returns:
97
+ List of (row, col) tuples for valid neighbors
98
+ """
99
+ neighbors = []
100
+ for dr in [-1, 0, 1]:
101
+ for dc in [-1, 0, 1]:
102
+ if dr == 0 and dc == 0:
103
+ continue
104
+ nr, nc = row + dr, col + dc
105
+ if 0 <= nr < self.size and 0 <= nc < self.size:
106
+ neighbors.append((nr, nc))
107
+ return neighbors
108
+
109
+ def _generate_path(self) -> bool:
110
+ """Generate a valid Hamiltonian path through the grid.
111
+
112
+ Returns:
113
+ True if path generation succeeded
114
+ """
115
+ # Use a greedy approach with random walks for efficiency
116
+ # Start from a random position
117
+ row = self._rng.randint(0, self.size - 1)
118
+ col = self._rng.randint(0, self.size - 1)
119
+
120
+ visited = set()
121
+ path = []
122
+
123
+ # Greedy walk through the grid
124
+ for _ in range(self.total_numbers):
125
+ visited.add((row, col))
126
+ path.append((row, col))
127
+
128
+ if len(path) == self.total_numbers:
129
+ break
130
+
131
+ # Find unvisited neighbors
132
+ neighbors = self._get_neighbors(row, col)
133
+ unvisited = [(r, c) for r, c in neighbors if (r, c) not in visited]
134
+
135
+ if not unvisited:
136
+ # Dead end - this attempt failed
137
+ return False
138
+
139
+ # Prefer neighbors with more unvisited neighbors (greedy heuristic)
140
+ def count_unvisited_neighbors(pos: tuple[int, int]) -> int:
141
+ r, c = pos
142
+ neighs = self._get_neighbors(r, c)
143
+ return sum(1 for nr, nc in neighs if (nr, nc) not in visited)
144
+
145
+ # Sort by number of unvisited neighbors (descending)
146
+ unvisited.sort(key=count_unvisited_neighbors, reverse=True)
147
+
148
+ # Pick one of the best choices (add some randomness)
149
+ if len(unvisited) > 1 and self._rng.random() < 0.3:
150
+ # 30% chance to pick second-best to add variety
151
+ row, col = unvisited[1] if len(unvisited) > 1 else unvisited[0]
152
+ else:
153
+ row, col = unvisited[0]
154
+
155
+ # Fill solution grid with the path
156
+ for i, (r, c) in enumerate(path, start=1):
157
+ self.solution[r][c] = i
158
+
159
+ return len(path) == self.total_numbers
160
+
161
+ def _generate_serpentine_path(self) -> None:
162
+ """Generate a serpentine (snake) path as a fallback.
163
+
164
+ This always succeeds and creates a readable pattern.
165
+ """
166
+ num = 1
167
+ for row in range(self.size):
168
+ if row % 2 == 0:
169
+ # Left to right
170
+ for col in range(self.size):
171
+ self.solution[row][col] = num
172
+ num += 1
173
+ else:
174
+ # Right to left
175
+ for col in range(self.size - 1, -1, -1):
176
+ self.solution[row][col] = num
177
+ num += 1
178
+
179
+ async def generate_puzzle(self) -> None:
180
+ """Generate a new Hidato puzzle."""
181
+ # Try greedy generation a few times, then fallback to serpentine
182
+ max_attempts = 50
183
+ success = False
184
+ for _ in range(max_attempts):
185
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
186
+ if self._generate_path():
187
+ success = True
188
+ break
189
+
190
+ # If no success, use serpentine pattern (always works)
191
+ if not success:
192
+ self._generate_serpentine_path()
193
+
194
+ # Create the puzzle by revealing some numbers
195
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
196
+
197
+ # Always reveal first and last numbers
198
+ for r in range(self.size):
199
+ for c in range(self.size):
200
+ if self.solution[r][c] == 1:
201
+ self.grid[r][c] = 1
202
+ elif self.solution[r][c] == self.total_numbers:
203
+ self.grid[r][c] = self.total_numbers
204
+
205
+ # Reveal additional clue numbers based on difficulty
206
+ num_clues = self.config.num_clues
207
+ all_positions = [(r, c) for r in range(self.size) for c in range(self.size)]
208
+ self._rng.shuffle(all_positions)
209
+
210
+ revealed = 2 # Already revealed first and last
211
+ for r, c in all_positions:
212
+ if revealed >= num_clues:
213
+ break
214
+ if self.grid[r][c] == 0: # Not already revealed
215
+ self.grid[r][c] = self.solution[r][c]
216
+ revealed += 1
217
+
218
+ # Store initial state
219
+ self.initial_grid = [row[:] for row in self.grid]
220
+ self.moves_made = 0
221
+ self.game_started = True
222
+
223
+ async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
224
+ """Place a number on the grid.
225
+
226
+ Args:
227
+ row: Row index (1-indexed, user-facing)
228
+ col: Column index (1-indexed, user-facing)
229
+ num: Number to place (1 to total_numbers, or 0 to clear)
230
+
231
+ Returns:
232
+ MoveResult with success status and message
233
+ """
234
+ # Convert to 0-indexed
235
+ row -= 1
236
+ col -= 1
237
+
238
+ # Validate coordinates
239
+ if not (0 <= row < self.size and 0 <= col < self.size):
240
+ return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
241
+
242
+ # Check if this cell is part of the initial puzzle
243
+ if self.initial_grid[row][col] != 0:
244
+ return MoveResult(success=False, message="Cannot modify initial clue cells.")
245
+
246
+ # Clear the cell
247
+ if num == 0:
248
+ self.grid[row][col] = 0
249
+ return MoveResult(success=True, message="Cell cleared.", state_changed=True)
250
+
251
+ # Validate number range
252
+ if not (1 <= num <= self.total_numbers):
253
+ return MoveResult(success=False, message=f"Invalid number. Use 1-{self.total_numbers} or 0 to clear.")
254
+
255
+ # Check if number is already used elsewhere
256
+ for r in range(self.size):
257
+ for c in range(self.size):
258
+ if (r, c) != (row, col) and self.grid[r][c] == num:
259
+ return MoveResult(success=False, message=f"Number {num} is already used at ({r + 1},{c + 1}).")
260
+
261
+ # Place the number
262
+ self.grid[row][col] = num
263
+ self.moves_made += 1
264
+ return MoveResult(success=True, message="Number placed successfully!", state_changed=True)
265
+
266
+ def is_complete(self) -> bool:
267
+ """Check if the puzzle is complete and correct."""
268
+ # Check all cells are filled
269
+ for row in range(self.size):
270
+ for col in range(self.size):
271
+ if self.grid[row][col] == 0:
272
+ return False
273
+ if self.grid[row][col] != self.solution[row][col]:
274
+ return False
275
+
276
+ # Check adjacency (each number n must be adjacent to n+1)
277
+ for num in range(1, self.total_numbers):
278
+ # Find position of num
279
+ pos_num = None
280
+ pos_next = None
281
+
282
+ for r in range(self.size):
283
+ for c in range(self.size):
284
+ if self.grid[r][c] == num:
285
+ pos_num = (r, c)
286
+ if self.grid[r][c] == num + 1:
287
+ pos_next = (r, c)
288
+
289
+ if pos_num is None or pos_next is None:
290
+ return False
291
+
292
+ # Check if they're adjacent
293
+ neighbors = self._get_neighbors(pos_num[0], pos_num[1])
294
+ if pos_next not in neighbors:
295
+ return False
296
+
297
+ return True
298
+
299
+ async def get_hint(self) -> tuple[Any, str] | None:
300
+ """Get a hint for the next move.
301
+
302
+ Returns:
303
+ Tuple of (hint_data, hint_message) or None if puzzle is complete
304
+ """
305
+ # Find an empty cell
306
+ empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0]
307
+ if not empty_cells:
308
+ return None
309
+
310
+ # Prefer cells that have known neighbors
311
+ for r, c in empty_cells:
312
+ target_num = self.solution[r][c]
313
+ neighbors = self._get_neighbors(r, c)
314
+
315
+ # Check if this cell's number has placed neighbors
316
+ for nr, nc in neighbors:
317
+ if self.grid[nr][nc] in [target_num - 1, target_num + 1]:
318
+ hint_data = (r + 1, c + 1, target_num)
319
+ hint_message = f"Try placing {target_num} at row {r + 1}, column {c + 1}"
320
+ return hint_data, hint_message
321
+
322
+ # Otherwise just give any empty cell
323
+ r, c = self._rng.choice(empty_cells)
324
+ target_num = self.solution[r][c]
325
+ hint_data = (r + 1, c + 1, target_num)
326
+ hint_message = f"Try placing {target_num} at row {r + 1}, column {c + 1}"
327
+ return hint_data, hint_message
328
+
329
+ def render_grid(self) -> str:
330
+ """Render the current puzzle state as ASCII art.
331
+
332
+ Returns:
333
+ String representation of the puzzle grid
334
+ """
335
+ lines = []
336
+
337
+ # Calculate cell width based on total numbers
338
+ cell_width = len(str(self.total_numbers)) + 1
339
+
340
+ # Header
341
+ header = " |"
342
+ for c in range(self.size):
343
+ header += f" {c + 1:^{cell_width}}"
344
+ lines.append(header)
345
+ lines.append(" +" + "-" * (cell_width + 1) * self.size)
346
+
347
+ # Grid rows
348
+ for r in range(self.size):
349
+ row_str = f"{r + 1:2}|"
350
+ for c in range(self.size):
351
+ cell = self.grid[r][c]
352
+ if cell == 0:
353
+ row_str += f" {'.':{cell_width}}"
354
+ else:
355
+ # Mark initial clues differently
356
+ if self.initial_grid[r][c] != 0:
357
+ row_str += f" {cell:{cell_width}}"
358
+ else:
359
+ row_str += f" {cell:{cell_width}}"
360
+ lines.append(row_str)
361
+
362
+ lines.append("\nLegend: . = empty, numbers = placed/clues")
363
+ lines.append(f"Goal: Fill grid with numbers 1-{self.total_numbers}, each adjacent to the next")
364
+
365
+ return "\n".join(lines)
366
+
367
+ def get_rules(self) -> str:
368
+ """Get the rules description for Hidato.
369
+
370
+ Returns:
371
+ Multi-line string describing the puzzle rules
372
+ """
373
+ return f"""HIDATO (NUMBER SNAKE) RULES:
374
+ - Fill the grid with consecutive numbers from 1 to {self.total_numbers}
375
+ - Each number must be adjacent (horizontally, vertically, or diagonally) to the next number
376
+ - Some numbers are given as clues
377
+ - Create one continuous path through all cells
378
+ - Each number appears exactly once"""
379
+
380
+ def get_commands(self) -> str:
381
+ """Get the available commands for Hidato.
382
+
383
+ Returns:
384
+ Multi-line string describing available commands
385
+ """
386
+ return """HIDATO COMMANDS:
387
+ place <row> <col> <num> - Place a number (e.g., 'place 1 5 7')
388
+ clear <row> <col> - Clear a cell you've filled (same as 'place <row> <col> 0')
389
+ show - Display the current grid
390
+ hint - Get a hint for the next move
391
+ check - Check your progress
392
+ solve - Show the solution (ends game)
393
+ menu - Return to game selection
394
+ quit - Exit the server"""
395
+
396
+ def get_stats(self) -> str:
397
+ """Get current game statistics.
398
+
399
+ Returns:
400
+ String with game stats
401
+ """
402
+ filled = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] != 0)
403
+ return f"Moves made: {self.moves_made} | Filled: {filled}/{self.total_numbers} | Seed: {self.seed}"
@@ -0,0 +1,6 @@
1
+ """Hitori puzzle game module."""
2
+
3
+ from .config import HitoriConfig
4
+ from .game import HitoriGame
5
+
6
+ __all__ = ["HitoriGame", "HitoriConfig"]
@@ -0,0 +1,23 @@
1
+ """Configuration for Hitori game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class HitoriConfig(BaseModel):
9
+ """Configuration for Hitori game."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
12
+ size: int = Field(ge=4, le=9, description="Grid size (NxN)")
13
+
14
+ @classmethod
15
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "HitoriConfig":
16
+ """Create config from difficulty level."""
17
+ config_map = {
18
+ DifficultyLevel.EASY: {"size": 4},
19
+ DifficultyLevel.MEDIUM: {"size": 5},
20
+ DifficultyLevel.HARD: {"size": 6},
21
+ }
22
+ params = config_map[difficulty]
23
+ return cls(difficulty=difficulty, **params)