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,390 @@
1
+ """Star Battle 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 StarBattleConfig
8
+
9
+
10
+ class StarBattleGame(PuzzleGame):
11
+ """Star Battle puzzle game.
12
+
13
+ Place stars in the grid such that:
14
+ - Each row contains exactly N stars
15
+ - Each column contains exactly N stars
16
+ - Each region contains exactly N stars
17
+ - Stars cannot touch each other (not even diagonally)
18
+ """
19
+
20
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
21
+ """Initialize a new Star Battle game.
22
+
23
+ Args:
24
+ difficulty: Game difficulty level (easy=6x6/1star, medium=8x8/2stars, hard=10x10/2stars)
25
+ """
26
+ super().__init__(difficulty, seed, **kwargs)
27
+
28
+ # Use pydantic config based on difficulty
29
+ self.config = StarBattleConfig.from_difficulty(self.difficulty)
30
+ self.size = self.config.size
31
+ self.stars_per_row = self.config.stars_per_row
32
+
33
+ # Grid: 0 = empty, 1 = star (player-placed)
34
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
35
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
36
+
37
+ # Regions: each cell belongs to a region (0 to num_regions-1)
38
+ self.regions = [[0 for _ in range(self.size)] for _ in range(self.size)]
39
+
40
+ @property
41
+ def name(self) -> str:
42
+ """The display name of this puzzle type."""
43
+ return "Star Battle"
44
+
45
+ @property
46
+ def description(self) -> str:
47
+ """A one-line description of this puzzle type."""
48
+ return f"Place {self.stars_per_row} star(s) in each row, column, and region without touching"
49
+
50
+ @property
51
+ def constraint_types(self) -> list[str]:
52
+ """Constraint types demonstrated by this puzzle."""
53
+ return ["placement_limits", "multi_region_constraints", "adjacency_avoidance", "counting"]
54
+
55
+ @property
56
+ def business_analogies(self) -> list[str]:
57
+ """Business problems this puzzle models."""
58
+ return ["resource_distribution", "conflict_avoidance", "quota_management", "spatial_planning"]
59
+
60
+ @property
61
+ def complexity_profile(self) -> dict[str, str]:
62
+ """Complexity profile of this puzzle."""
63
+ return {"reasoning_type": "deductive", "search_space": "large", "constraint_density": "dense"}
64
+
65
+ @property
66
+ def optimal_steps(self) -> int | None:
67
+ """Minimum steps = stars to place."""
68
+ if not hasattr(self, "solution") or not self.solution:
69
+ return None
70
+ return sum(sum(row) for row in self.solution)
71
+
72
+ @property
73
+ def difficulty_profile(self) -> "DifficultyProfile":
74
+ """Difficulty characteristics for Star Battle."""
75
+ from ...models import DifficultyLevel
76
+
77
+ logic_depth = {
78
+ DifficultyLevel.EASY.value: 3,
79
+ DifficultyLevel.MEDIUM.value: 5,
80
+ DifficultyLevel.HARD.value: 6,
81
+ }.get(self.difficulty.value, 4)
82
+ return DifficultyProfile(
83
+ logic_depth=logic_depth,
84
+ branching_factor=3.0,
85
+ state_observability=1.0,
86
+ constraint_density=0.6,
87
+ )
88
+
89
+ def _get_all_adjacent(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 all adjacent cells
98
+ """
99
+ adjacent = []
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
+ adjacent.append((nr, nc))
107
+ return adjacent
108
+
109
+ def _generate_regions(self) -> None:
110
+ """Generate regions for the puzzle."""
111
+ # Simple region generation: create rectangular-ish regions
112
+ # Create a simple grid division
113
+ if self.size == 6:
114
+ # 6x6: create 6 regions of 6 cells each
115
+ patterns = [
116
+ [(0, 0), (0, 1), (1, 0), (1, 1), (2, 0), (2, 1)],
117
+ [(0, 2), (0, 3), (1, 2), (1, 3), (2, 2), (2, 3)],
118
+ [(0, 4), (0, 5), (1, 4), (1, 5), (2, 4), (2, 5)],
119
+ [(3, 0), (3, 1), (4, 0), (4, 1), (5, 0), (5, 1)],
120
+ [(3, 2), (3, 3), (4, 2), (4, 3), (5, 2), (5, 3)],
121
+ [(3, 4), (3, 5), (4, 4), (4, 5), (5, 4), (5, 5)],
122
+ ]
123
+ for region_id, cells in enumerate(patterns):
124
+ for r, c in cells:
125
+ self.regions[r][c] = region_id
126
+ else:
127
+ # For other sizes, use row-based regions
128
+ for r in range(self.size):
129
+ for c in range(self.size):
130
+ self.regions[r][c] = r
131
+
132
+ async def generate_puzzle(self) -> None:
133
+ """Generate a new Star Battle puzzle."""
134
+ # Generate regions
135
+ self._generate_regions()
136
+
137
+ # Reset grids
138
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
139
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
140
+
141
+ # Try to place stars that satisfy all constraints
142
+ max_attempts = 100
143
+ for _ in range(max_attempts):
144
+ if self._try_place_stars():
145
+ break
146
+
147
+ # If failed, create a simple valid solution
148
+ if sum(sum(row) for row in self.solution) < self.size * self.stars_per_row:
149
+ self._create_simple_solution()
150
+
151
+ self.moves_made = 0
152
+ self.game_started = True
153
+
154
+ def _try_place_stars(self) -> bool:
155
+ """Try to place stars that satisfy all constraints.
156
+
157
+ Returns:
158
+ True if successful
159
+ """
160
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
161
+
162
+ # Place stars row by row
163
+ for row in range(self.size):
164
+ stars_placed = 0
165
+ attempts = 0
166
+ max_attempts_per_row = 100
167
+
168
+ while stars_placed < self.stars_per_row and attempts < max_attempts_per_row:
169
+ attempts += 1
170
+ col = self._rng.randint(0, self.size - 1)
171
+
172
+ # Check if we can place a star here
173
+ if self.solution[row][col] == 1:
174
+ continue
175
+
176
+ # Check column constraint
177
+ col_count = sum(self.solution[r][col] for r in range(self.size))
178
+ if col_count >= self.stars_per_row:
179
+ continue
180
+
181
+ # Check region constraint
182
+ region_id = self.regions[row][col]
183
+ region_count = sum(
184
+ self.solution[r][c]
185
+ for r in range(self.size)
186
+ for c in range(self.size)
187
+ if self.regions[r][c] == region_id
188
+ )
189
+ if region_count >= self.stars_per_row:
190
+ continue
191
+
192
+ # Check adjacency (no touching stars)
193
+ adjacent = self._get_all_adjacent(row, col)
194
+ if any(self.solution[ar][ac] == 1 for ar, ac in adjacent):
195
+ continue
196
+
197
+ # Place the star
198
+ self.solution[row][col] = 1
199
+ stars_placed += 1
200
+
201
+ if stars_placed < self.stars_per_row:
202
+ return False
203
+
204
+ return True
205
+
206
+ def _create_simple_solution(self) -> None:
207
+ """Create a simple valid solution as fallback."""
208
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
209
+
210
+ # Place stars in a diagonal pattern with spacing
211
+ spacing = max(2, self.size // self.stars_per_row)
212
+ for i in range(self.stars_per_row):
213
+ for row in range(self.size):
214
+ col = (row * spacing + i * (self.size // self.stars_per_row)) % self.size
215
+ if self.solution[row][col] == 0:
216
+ # Check adjacency
217
+ adjacent = self._get_all_adjacent(row, col)
218
+ if not any(self.solution[ar][ac] == 1 for ar, ac in adjacent):
219
+ self.solution[row][col] = 1
220
+
221
+ async def validate_move(self, row: int, col: int, action: str = "place") -> MoveResult:
222
+ """Place or remove a star.
223
+
224
+ Args:
225
+ row: Row index (1-indexed, user-facing)
226
+ col: Column index (1-indexed, user-facing)
227
+ action: "place" or "remove" (default: "place")
228
+
229
+ Returns:
230
+ MoveResult with success status and message
231
+ """
232
+ # Convert to 0-indexed
233
+ row -= 1
234
+ col -= 1
235
+
236
+ # Validate coordinates
237
+ if not (0 <= row < self.size and 0 <= col < self.size):
238
+ return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
239
+
240
+ action = action.lower()
241
+
242
+ if action == "remove":
243
+ if self.grid[row][col] != 1:
244
+ return MoveResult(success=False, message="No star to remove at this position.")
245
+ self.grid[row][col] = 0
246
+ self.moves_made += 1
247
+ return MoveResult(success=True, message="Star removed.", state_changed=True)
248
+
249
+ elif action == "place":
250
+ if self.grid[row][col] == 1:
251
+ return MoveResult(success=False, message="Star already placed here.")
252
+
253
+ # Check if star would touch another star
254
+ adjacent = self._get_all_adjacent(row, col)
255
+ if any(self.grid[ar][ac] == 1 for ar, ac in adjacent):
256
+ return MoveResult(success=False, message="Stars cannot touch each other (not even diagonally).")
257
+
258
+ self.grid[row][col] = 1
259
+ self.moves_made += 1
260
+ return MoveResult(success=True, message="Star placed!", state_changed=True)
261
+
262
+ else:
263
+ return MoveResult(success=False, message="Invalid action. Use 'place' or 'remove'.")
264
+
265
+ def is_complete(self) -> bool:
266
+ """Check if the puzzle is complete and correct."""
267
+ # Check row counts
268
+ for row in range(self.size):
269
+ count = sum(self.grid[row])
270
+ if count != self.stars_per_row:
271
+ return False
272
+
273
+ # Check column counts
274
+ for col in range(self.size):
275
+ count = sum(self.grid[row][col] for row in range(self.size))
276
+ if count != self.stars_per_row:
277
+ return False
278
+
279
+ # Check region counts
280
+ num_regions = max(max(row) for row in self.regions) + 1
281
+ for region_id in range(num_regions):
282
+ count = sum(
283
+ self.grid[r][c] for r in range(self.size) for c in range(self.size) if self.regions[r][c] == region_id
284
+ )
285
+ if count != self.stars_per_row:
286
+ return False
287
+
288
+ # Check no stars touch
289
+ for r in range(self.size):
290
+ for c in range(self.size):
291
+ if self.grid[r][c] == 1:
292
+ adjacent = self._get_all_adjacent(r, c)
293
+ if any(self.grid[ar][ac] == 1 for ar, ac in adjacent):
294
+ return False
295
+
296
+ return True
297
+
298
+ async def get_hint(self) -> tuple[Any, str] | None:
299
+ """Get a hint for the next move.
300
+
301
+ Returns:
302
+ Tuple of (hint_data, hint_message) or None if puzzle is complete
303
+ """
304
+ # Find a star location from solution that hasn't been placed
305
+ for r in range(self.size):
306
+ for c in range(self.size):
307
+ if self.solution[r][c] == 1 and self.grid[r][c] != 1:
308
+ hint_data = (r + 1, c + 1, "place")
309
+ hint_message = f"Try placing a star at row {r + 1}, column {c + 1}"
310
+ return hint_data, hint_message
311
+
312
+ # Find incorrectly placed star
313
+ for r in range(self.size):
314
+ for c in range(self.size):
315
+ if self.grid[r][c] == 1 and self.solution[r][c] != 1:
316
+ hint_data = (r + 1, c + 1, "remove")
317
+ hint_message = f"Remove the star at row {r + 1}, column {c + 1}"
318
+ return hint_data, hint_message
319
+
320
+ return None
321
+
322
+ def render_grid(self) -> str:
323
+ """Render the current puzzle state as ASCII art.
324
+
325
+ Returns:
326
+ String representation of the puzzle grid
327
+ """
328
+ lines = []
329
+
330
+ # Header
331
+ header = " |"
332
+ for c in range(self.size):
333
+ header += f" {c + 1}"
334
+ lines.append(header)
335
+ lines.append(" +" + "--" * self.size)
336
+
337
+ # Grid rows
338
+ for r in range(self.size):
339
+ row_str = f" {r + 1} |"
340
+ for c in range(self.size):
341
+ if self.grid[r][c] == 1:
342
+ row_str += " *"
343
+ else:
344
+ # Show region boundaries
345
+ row_str += " ."
346
+ lines.append(row_str)
347
+
348
+ lines.append("\nLegend: * = star, . = empty")
349
+ lines.append(f"Goal: Place {self.stars_per_row} star(s) in each row, column, and region")
350
+
351
+ return "\n".join(lines)
352
+
353
+ def get_rules(self) -> str:
354
+ """Get the rules description for Star Battle.
355
+
356
+ Returns:
357
+ Multi-line string describing the puzzle rules
358
+ """
359
+ return f"""STAR BATTLE RULES:
360
+ - Place {self.stars_per_row} star(s) in each row
361
+ - Place {self.stars_per_row} star(s) in each column
362
+ - Place {self.stars_per_row} star(s) in each region
363
+ - Stars cannot touch each other, not even diagonally
364
+ - All stars must be placed according to these constraints"""
365
+
366
+ def get_commands(self) -> str:
367
+ """Get the available commands for Star Battle.
368
+
369
+ Returns:
370
+ Multi-line string describing available commands
371
+ """
372
+ return """STAR BATTLE COMMANDS:
373
+ place <row> <col> - Place a star (e.g., 'place 2 3')
374
+ remove <row> <col> - Remove a star (e.g., 'remove 2 3')
375
+ show - Display the current grid
376
+ hint - Get a hint for the next move
377
+ check - Check your progress
378
+ solve - Show the solution (ends game)
379
+ menu - Return to game selection
380
+ quit - Exit the server"""
381
+
382
+ def get_stats(self) -> str:
383
+ """Get current game statistics.
384
+
385
+ Returns:
386
+ String with game stats
387
+ """
388
+ placed = sum(sum(row) for row in self.grid)
389
+ required = self.size * self.stars_per_row
390
+ return f"Moves made: {self.moves_made} | Stars placed: {placed}/{required} | Seed: {self.seed}"
@@ -0,0 +1,7 @@
1
+ """Sudoku puzzle game module."""
2
+
3
+ from .commands import SudokuCommandHandler
4
+ from .config import SudokuConfig
5
+ from .game import SudokuGame
6
+
7
+ __all__ = ["SudokuGame", "SudokuConfig", "SudokuCommandHandler"]
@@ -0,0 +1,96 @@
1
+ """Command handler for Sudoku game."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ...models import GameCommand, MoveResult
6
+ from .._base import CommandResult, GameCommandHandler
7
+
8
+ if TYPE_CHECKING:
9
+ from .game import SudokuGame
10
+
11
+
12
+ class SudokuCommandHandler(GameCommandHandler):
13
+ """Handles commands for Sudoku game."""
14
+
15
+ game: "SudokuGame"
16
+
17
+ @property
18
+ def supported_commands(self) -> set[GameCommand]:
19
+ """Return the set of GameCommand enums this handler supports."""
20
+ return {GameCommand.PLACE, GameCommand.CLEAR}
21
+
22
+ async def handle_command(self, cmd: GameCommand, args: list[str]) -> CommandResult:
23
+ """Handle a Sudoku-specific command.
24
+
25
+ Args:
26
+ cmd: The GameCommand enum value
27
+ args: List of string arguments (already split from input)
28
+
29
+ Returns:
30
+ CommandResult with the move result and display flags
31
+ """
32
+ if cmd == GameCommand.PLACE:
33
+ return await self._handle_place(args)
34
+ elif cmd == GameCommand.CLEAR:
35
+ return await self._handle_clear(args)
36
+ else:
37
+ return self.error_result(f"Unknown command: {cmd}")
38
+
39
+ async def _handle_place(self, args: list[str]) -> CommandResult:
40
+ """Handle the PLACE command.
41
+
42
+ Args:
43
+ args: [row, col, num] - all as strings
44
+
45
+ Returns:
46
+ CommandResult with move result
47
+ """
48
+ if len(args) != 3:
49
+ return CommandResult(
50
+ result=MoveResult(success=False, message="Usage: place <row> <col> <num>\nExample: place 1 5 7"),
51
+ should_display=False,
52
+ )
53
+
54
+ row = self.parse_int(args[0], "row")
55
+ col = self.parse_int(args[1], "col")
56
+ num = self.parse_int(args[2], "num")
57
+
58
+ if row is None or col is None or num is None:
59
+ return self.error_result("Row, column, and number must be integers.")
60
+
61
+ result = await self.game.validate_move(row, col, num)
62
+
63
+ return CommandResult(
64
+ result=result,
65
+ should_display=result.success,
66
+ is_game_over=result.success and self.game.is_complete(),
67
+ )
68
+
69
+ async def _handle_clear(self, args: list[str]) -> CommandResult:
70
+ """Handle the CLEAR command.
71
+
72
+ Args:
73
+ args: [row, col] - as strings
74
+
75
+ Returns:
76
+ CommandResult with move result
77
+ """
78
+ if len(args) != 2:
79
+ return CommandResult(
80
+ result=MoveResult(success=False, message="Usage: clear <row> <col>"),
81
+ should_display=False,
82
+ )
83
+
84
+ row = self.parse_int(args[0], "row")
85
+ col = self.parse_int(args[1], "col")
86
+
87
+ if row is None or col is None:
88
+ return self.error_result("Row and column must be integers.")
89
+
90
+ # Clear is just a validate_move with num=0
91
+ result = await self.game.validate_move(row, col, 0)
92
+
93
+ return CommandResult(
94
+ result=result,
95
+ should_display=result.success,
96
+ )
@@ -0,0 +1,22 @@
1
+ """Configuration for Sudoku game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class SudokuConfig(BaseModel):
9
+ """Configuration for Sudoku game."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
12
+ cells_to_remove: int = Field(ge=0, le=64, description="Number of cells to remove from solution")
13
+
14
+ @classmethod
15
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "SudokuConfig":
16
+ """Create config from difficulty level."""
17
+ cells_map = {
18
+ DifficultyLevel.EASY: 35,
19
+ DifficultyLevel.MEDIUM: 45,
20
+ DifficultyLevel.HARD: 55,
21
+ }
22
+ return cls(difficulty=difficulty, cells_to_remove=cells_map[difficulty])