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,20 @@
1
+ """
2
+ Dataset export utilities for puzzle games.
3
+
4
+ Provides JSONL export for:
5
+ - Training data generation
6
+ - Benchmark dataset creation
7
+ - Episode recording for analysis
8
+ """
9
+
10
+ from chuk_puzzles_gym.export.dataset import (
11
+ DatasetExporter,
12
+ export_problems,
13
+ generate_dataset,
14
+ )
15
+
16
+ __all__ = [
17
+ "DatasetExporter",
18
+ "generate_dataset",
19
+ "export_problems",
20
+ ]
@@ -0,0 +1,376 @@
1
+ """
2
+ Dataset export for puzzle games.
3
+
4
+ Generates JSONL datasets for training and benchmarking.
5
+ Includes step-by-step solution traces for teacher-forcing training.
6
+
7
+ Uses chuk-gym-core's Problem schema and JSONLExporter for consistency
8
+ with chuk-math-gym's export format.
9
+ """
10
+
11
+ import asyncio
12
+ from pathlib import Path
13
+ from typing import Any, TextIO
14
+
15
+ from chuk_gym_core import DifficultyLevel, DifficultyProfile, JSONLExporter, Problem
16
+
17
+ from chuk_puzzles_gym.games import AVAILABLE_GAMES
18
+ from chuk_puzzles_gym.games._base import PuzzleGame
19
+ from chuk_puzzles_gym.models import SolverConfig
20
+ from chuk_puzzles_gym.trace import TraceGenerator
21
+
22
+
23
+ class DatasetExporter:
24
+ """
25
+ Export puzzle problems to JSONL format for training.
26
+
27
+ Uses chuk-gym-core's Problem schema and JSONLExporter for
28
+ consistency with chuk-math-gym's export format.
29
+
30
+ Usage:
31
+ exporter = DatasetExporter("puzzles.jsonl")
32
+
33
+ # Generate problems for all games
34
+ await exporter.export_all_games(
35
+ count_per_game=100,
36
+ difficulties=[DifficultyLevel.EASY, DifficultyLevel.MEDIUM],
37
+ )
38
+
39
+ # Or specific game
40
+ await exporter.export_game(
41
+ game_name="sudoku",
42
+ count=1000,
43
+ difficulty=DifficultyLevel.HARD,
44
+ )
45
+
46
+ exporter.close()
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ output: str | Path | TextIO,
52
+ include_solution: bool = True,
53
+ include_trace: bool = True,
54
+ ):
55
+ """
56
+ Initialize the exporter.
57
+
58
+ Args:
59
+ output: Output file path or file handle
60
+ include_solution: Whether to include canonical solutions
61
+ include_trace: Whether to include step-by-step traces
62
+ """
63
+ self.include_solution = include_solution
64
+ self._trace_generator = TraceGenerator()
65
+
66
+ # Use core JSONLExporter for consistent output format
67
+ self._exporter = JSONLExporter(
68
+ output=output,
69
+ include_trace=include_trace,
70
+ )
71
+
72
+ def __enter__(self) -> "DatasetExporter":
73
+ return self
74
+
75
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
76
+ self.close()
77
+
78
+ def close(self) -> None:
79
+ """Close the output file."""
80
+ self._exporter.close()
81
+
82
+ async def export_game(
83
+ self,
84
+ game_name: str,
85
+ count: int,
86
+ difficulty: DifficultyLevel | str = DifficultyLevel.MEDIUM,
87
+ start_seed: int = 0,
88
+ solver_config: SolverConfig | None = None,
89
+ ) -> int:
90
+ """
91
+ Export problems for a specific game.
92
+
93
+ Args:
94
+ game_name: Name of the puzzle game
95
+ count: Number of problems to generate
96
+ difficulty: Difficulty level
97
+ start_seed: Starting seed for reproducibility
98
+ solver_config: Solver configuration
99
+
100
+ Returns:
101
+ Number of problems exported
102
+ """
103
+ if game_name not in AVAILABLE_GAMES:
104
+ raise ValueError(f"Unknown game: {game_name}")
105
+
106
+ game_class = AVAILABLE_GAMES[game_name]
107
+ if isinstance(difficulty, str):
108
+ difficulty = DifficultyLevel(difficulty)
109
+
110
+ exported = 0
111
+ for i in range(count):
112
+ seed = start_seed + i
113
+
114
+ # Create game and generate puzzle (game_class is always a concrete subclass)
115
+ game = game_class( # type: ignore[abstract]
116
+ difficulty=difficulty,
117
+ seed=seed,
118
+ solver_config=solver_config or SolverConfig(),
119
+ )
120
+ await game.generate_puzzle()
121
+
122
+ # Generate solution trace
123
+ trace = self._trace_generator.generate(game)
124
+
125
+ # Convert game to Problem and export using core exporter
126
+ problem = self._game_to_problem(game, seed, difficulty)
127
+ self._exporter.write_problem(problem, trace)
128
+ exported += 1
129
+
130
+ return exported
131
+
132
+ async def export_all_games(
133
+ self,
134
+ count_per_game: int,
135
+ difficulties: list[DifficultyLevel] | None = None,
136
+ start_seed: int = 0,
137
+ games: list[str] | None = None,
138
+ ) -> int:
139
+ """
140
+ Export problems for multiple games.
141
+
142
+ Args:
143
+ count_per_game: Number of problems per game/difficulty combo
144
+ difficulties: Difficulty levels to include (default: all)
145
+ start_seed: Starting seed
146
+ games: Specific games to include (default: all)
147
+
148
+ Returns:
149
+ Total number of problems exported
150
+ """
151
+ if difficulties is None:
152
+ difficulties = [
153
+ DifficultyLevel.EASY,
154
+ DifficultyLevel.MEDIUM,
155
+ DifficultyLevel.HARD,
156
+ ]
157
+
158
+ game_names = games or list(AVAILABLE_GAMES.keys())
159
+ total = 0
160
+
161
+ for game_name in game_names:
162
+ for difficulty in difficulties:
163
+ count = await self.export_game(
164
+ game_name=game_name,
165
+ count=count_per_game,
166
+ difficulty=difficulty,
167
+ start_seed=start_seed + total,
168
+ )
169
+ total += count
170
+
171
+ return total
172
+
173
+ def _game_to_problem(
174
+ self,
175
+ game: PuzzleGame,
176
+ seed: int,
177
+ difficulty: DifficultyLevel,
178
+ ) -> Problem:
179
+ """Convert a game instance to a chuk-gym-core Problem.
180
+
181
+ Uses the core Problem schema for consistency with chuk-math-gym.
182
+ """
183
+ domain = game.name.lower().replace(" ", "_")
184
+ profile = game.difficulty_profile
185
+
186
+ # Build gold_answer from canonical solution if available
187
+ gold_answer = None
188
+ if self.include_solution:
189
+ canonical = game.canonical_solution
190
+ if canonical:
191
+ gold_answer = str(canonical)
192
+
193
+ # Create Problem using core schema
194
+ return Problem(
195
+ # Identity
196
+ id=Problem.generate_id(domain, difficulty, seed),
197
+ seed=seed,
198
+ # Classification
199
+ domain=domain,
200
+ difficulty=difficulty,
201
+ # Content
202
+ prompt=f"{game.name}: {game.description}\n\n{game.get_rules()}\n\n{game.render_grid()}",
203
+ initial_state=game.grid if hasattr(game, "grid") else None,
204
+ gold_answer=gold_answer,
205
+ # Constraint metadata
206
+ constraint_types=game.constraint_types,
207
+ business_analogies=game.business_analogies,
208
+ # Difficulty profile
209
+ difficulty_profile=DifficultyProfile(
210
+ logic_depth=profile.logic_depth,
211
+ branching_factor=profile.branching_factor,
212
+ state_observability=profile.state_observability,
213
+ constraint_density=profile.constraint_density,
214
+ ),
215
+ # Metadata
216
+ operation_count=game.optimal_steps,
217
+ tags=[domain, difficulty.value],
218
+ )
219
+
220
+ @property
221
+ def count(self) -> int:
222
+ """Number of records written."""
223
+ return self._exporter.count
224
+
225
+ def flush(self) -> None:
226
+ """Flush the output buffer."""
227
+ self._exporter.flush()
228
+
229
+
230
+ async def generate_dataset(
231
+ output_path: str | Path,
232
+ games: list[str] | None = None,
233
+ count_per_game: int = 100,
234
+ difficulties: list[str] | None = None,
235
+ start_seed: int = 0,
236
+ include_trace: bool = True,
237
+ ) -> int:
238
+ """
239
+ Generate a complete puzzle dataset.
240
+
241
+ Args:
242
+ output_path: Path to output JSONL file
243
+ games: Games to include (default: all)
244
+ count_per_game: Problems per game/difficulty
245
+ difficulties: Difficulties to include (default: easy, medium, hard)
246
+ start_seed: Starting seed
247
+ include_trace: Whether to include step-by-step traces
248
+
249
+ Returns:
250
+ Total number of problems generated
251
+ """
252
+ diff_levels = None
253
+ if difficulties:
254
+ diff_levels = [DifficultyLevel(d) for d in difficulties]
255
+
256
+ with DatasetExporter(
257
+ output_path,
258
+ include_trace=include_trace,
259
+ ) as exporter:
260
+ total = await exporter.export_all_games(
261
+ count_per_game=count_per_game,
262
+ difficulties=diff_levels,
263
+ start_seed=start_seed,
264
+ games=games,
265
+ )
266
+
267
+ return total
268
+
269
+
270
+ async def export_problems(
271
+ game_name: str,
272
+ count: int,
273
+ output_path: str | Path,
274
+ difficulty: str = "medium",
275
+ start_seed: int = 0,
276
+ ) -> int:
277
+ """
278
+ Export problems for a single game.
279
+
280
+ Args:
281
+ game_name: Name of the puzzle game
282
+ count: Number of problems
283
+ output_path: Path to output JSONL file
284
+ difficulty: Difficulty level
285
+ start_seed: Starting seed
286
+
287
+ Returns:
288
+ Number of problems exported
289
+ """
290
+ with DatasetExporter(output_path) as exporter:
291
+ exported = await exporter.export_game(
292
+ game_name=game_name,
293
+ count=count,
294
+ difficulty=DifficultyLevel(difficulty),
295
+ start_seed=start_seed,
296
+ )
297
+
298
+ return exported
299
+
300
+
301
+ def main() -> None:
302
+ """CLI entry point for dataset generation."""
303
+ import argparse
304
+
305
+ parser = argparse.ArgumentParser(description="Generate puzzle datasets for training and benchmarking")
306
+ parser.add_argument(
307
+ "-o",
308
+ "--output",
309
+ default="puzzles.jsonl",
310
+ help="Output file path (default: puzzles.jsonl)",
311
+ )
312
+ parser.add_argument(
313
+ "-g",
314
+ "--games",
315
+ nargs="+",
316
+ help="Games to include (default: all)",
317
+ )
318
+ parser.add_argument(
319
+ "-n",
320
+ "--count",
321
+ type=int,
322
+ default=100,
323
+ help="Problems per game/difficulty (default: 100)",
324
+ )
325
+ parser.add_argument(
326
+ "-d",
327
+ "--difficulties",
328
+ nargs="+",
329
+ default=["easy", "medium", "hard"],
330
+ help="Difficulties to include",
331
+ )
332
+ parser.add_argument(
333
+ "-s",
334
+ "--seed",
335
+ type=int,
336
+ default=0,
337
+ help="Starting seed (default: 0)",
338
+ )
339
+ parser.add_argument(
340
+ "--no-trace",
341
+ action="store_true",
342
+ help="Exclude step-by-step solution traces",
343
+ )
344
+ parser.add_argument(
345
+ "--list-games",
346
+ action="store_true",
347
+ help="List available games and exit",
348
+ )
349
+
350
+ args = parser.parse_args()
351
+
352
+ if args.list_games:
353
+ print("Available games:")
354
+ for name in sorted(AVAILABLE_GAMES.keys()):
355
+ game_class = AVAILABLE_GAMES[name]
356
+ # Create instance to get description (game_class is always a concrete subclass)
357
+ game = game_class() # type: ignore[abstract]
358
+ print(f" {name}: {game.description}")
359
+ return
360
+
361
+ total = asyncio.run(
362
+ generate_dataset(
363
+ output_path=args.output,
364
+ games=args.games,
365
+ count_per_game=args.count,
366
+ difficulties=args.difficulties,
367
+ start_seed=args.seed,
368
+ include_trace=not args.no_trace,
369
+ )
370
+ )
371
+
372
+ print(f"Generated {total} problems -> {args.output}")
373
+
374
+
375
+ if __name__ == "__main__":
376
+ main()
@@ -0,0 +1,94 @@
1
+ """Puzzle game implementations."""
2
+
3
+ from .binary import BinaryPuzzleGame
4
+ from .bridges import BridgesGame
5
+ from .einstein import EinsteinGame
6
+ from .fillomino import FillominoGame
7
+ from .futoshiki import FutoshikiGame
8
+ from .hidato import HidatoGame
9
+ from .hitori import HitoriGame
10
+ from .kakuro import KakuroGame
11
+ from .kenken import KenKenGame
12
+ from .killer_sudoku import KillerSudokuGame
13
+ from .knapsack import KnapsackGame
14
+ from .lights_out import LightsOutGame
15
+ from .logic_grid import LogicGridGame
16
+ from .mastermind import MastermindGame
17
+ from .minesweeper import MinesweeperGame
18
+ from .nonogram import NonogramGame
19
+ from .nurikabe import NurikabeGame
20
+ from .scheduler import SchedulerGame
21
+ from .shikaku import ShikakuGame
22
+ from .slitherlink import SlitherlinkGame
23
+ from .sokoban import SokobanGame
24
+ from .star_battle import StarBattleGame
25
+ from .sudoku import SudokuGame
26
+ from .sudoku.commands import SudokuCommandHandler
27
+ from .tents import TentsGame
28
+
29
+ # Registry of available games
30
+ AVAILABLE_GAMES = {
31
+ # Classic Logic Puzzles
32
+ "sudoku": SudokuGame,
33
+ "kenken": KenKenGame,
34
+ "kakuro": KakuroGame,
35
+ "binary": BinaryPuzzleGame,
36
+ "futoshiki": FutoshikiGame,
37
+ "nonogram": NonogramGame,
38
+ "logic": LogicGridGame,
39
+ # Advanced CP-SAT Puzzles
40
+ "killer": KillerSudokuGame,
41
+ "lights": LightsOutGame,
42
+ "mastermind": MastermindGame,
43
+ "slither": SlitherlinkGame,
44
+ "bridges": BridgesGame,
45
+ "hitori": HitoriGame,
46
+ "shikaku": ShikakuGame,
47
+ # Specialized Constraint Puzzles
48
+ "hidato": HidatoGame,
49
+ "tents": TentsGame,
50
+ "fillomino": FillominoGame,
51
+ "star_battle": StarBattleGame,
52
+ "sokoban": SokobanGame,
53
+ # Optimization Challenges
54
+ "knapsack": KnapsackGame,
55
+ "scheduler": SchedulerGame,
56
+ # Advanced Reasoning
57
+ "nurikabe": NurikabeGame,
58
+ "einstein": EinsteinGame,
59
+ "minesweeper": MinesweeperGame,
60
+ }
61
+
62
+ # Registry of game command handlers (games that have moved command handling out of server)
63
+ GAME_COMMAND_HANDLERS = {
64
+ "sudoku": SudokuCommandHandler,
65
+ }
66
+
67
+ __all__ = [
68
+ "SudokuGame",
69
+ "KenKenGame",
70
+ "KakuroGame",
71
+ "BinaryPuzzleGame",
72
+ "BridgesGame",
73
+ "FillominoGame",
74
+ "FutoshikiGame",
75
+ "HidatoGame",
76
+ "HitoriGame",
77
+ "NonogramGame",
78
+ "LogicGridGame",
79
+ "KillerSudokuGame",
80
+ "LightsOutGame",
81
+ "MastermindGame",
82
+ "ShikakuGame",
83
+ "SlitherlinkGame",
84
+ "SokobanGame",
85
+ "StarBattleGame",
86
+ "TentsGame",
87
+ "KnapsackGame",
88
+ "SchedulerGame",
89
+ "NurikabeGame",
90
+ "EinsteinGame",
91
+ "MinesweeperGame",
92
+ "AVAILABLE_GAMES",
93
+ "GAME_COMMAND_HANDLERS",
94
+ ]
@@ -0,0 +1,6 @@
1
+ """Base classes for puzzle games."""
2
+
3
+ from .commands import CommandResult, GameCommandHandler
4
+ from .game import PuzzleGame
5
+
6
+ __all__ = ["PuzzleGame", "GameCommandHandler", "CommandResult"]
@@ -0,0 +1,91 @@
1
+ """Abstract base class for game command handlers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ from ...models import GameCommand, MoveResult
8
+
9
+ if TYPE_CHECKING:
10
+ from .game import PuzzleGame
11
+
12
+
13
+ @dataclass
14
+ class CommandResult:
15
+ """Result of executing a game command."""
16
+
17
+ result: MoveResult
18
+ should_display: bool = True
19
+ is_game_over: bool = False
20
+
21
+
22
+ class GameCommandHandler(ABC):
23
+ """Abstract base class for handling game-specific commands.
24
+
25
+ Each game implements its own command handler that knows how to:
26
+ - Parse command arguments
27
+ - Validate and execute moves
28
+ - Return appropriate results
29
+
30
+ This decouples game logic from server routing.
31
+ """
32
+
33
+ def __init__(self, game: "PuzzleGame"):
34
+ """Initialize with the game instance.
35
+
36
+ Args:
37
+ game: The puzzle game instance to handle commands for
38
+ """
39
+ self.game = game
40
+
41
+ @property
42
+ @abstractmethod
43
+ def supported_commands(self) -> set[GameCommand]:
44
+ """Return the set of GameCommand enums this handler supports.
45
+
46
+ Returns:
47
+ Set of GameCommand values this game responds to
48
+ """
49
+ pass
50
+
51
+ @abstractmethod
52
+ async def handle_command(self, cmd: GameCommand, args: list[str]) -> CommandResult:
53
+ """Handle a game-specific command.
54
+
55
+ Args:
56
+ cmd: The GameCommand enum value
57
+ args: List of string arguments (already split from input)
58
+
59
+ Returns:
60
+ CommandResult with the move result and display flags
61
+ """
62
+ pass
63
+
64
+ def parse_int(self, value: str, name: str) -> int | None:
65
+ """Helper to parse an integer argument.
66
+
67
+ Args:
68
+ value: String value to parse
69
+ name: Name of the argument (for error messages)
70
+
71
+ Returns:
72
+ Parsed integer or None if invalid
73
+ """
74
+ try:
75
+ return int(value)
76
+ except ValueError:
77
+ return None
78
+
79
+ def error_result(self, message: str) -> CommandResult:
80
+ """Create an error CommandResult.
81
+
82
+ Args:
83
+ message: Error message to display
84
+
85
+ Returns:
86
+ CommandResult with failed MoveResult
87
+ """
88
+ return CommandResult(
89
+ result=MoveResult(success=False, message=message),
90
+ should_display=False,
91
+ )