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,1171 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Puzzle Arcade Server
4
+
5
+ A multi-game telnet server hosting various logic puzzle games.
6
+ LLMs with MCP solver access can telnet in and solve these puzzles.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import os
13
+ import sys
14
+
15
+ # Add the chuk-protocol-server to the path
16
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "chuk-protocol-server", "src"))
17
+
18
+ from chuk_protocol_server.handlers.telnet_handler import TelnetHandler
19
+ from chuk_protocol_server.servers.telnet_server import TelnetServer
20
+
21
+ from .games import AVAILABLE_GAMES, GAME_COMMAND_HANDLERS
22
+ from .games._base import GameCommandHandler, PuzzleGame
23
+ from .models import DifficultyLevel, GameCommand, OutputMode
24
+
25
+ # Configure logging
26
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
27
+
28
+ logger = logging.getLogger("puzzle-arcade")
29
+
30
+
31
+ class ArcadeHandler(TelnetHandler):
32
+ """Handler for Puzzle Arcade telnet sessions."""
33
+
34
+ async def on_connect(self) -> None:
35
+ """Initialize state when a client connects."""
36
+ await super().on_connect()
37
+ self.current_game: PuzzleGame | None = None
38
+ self.game_handler: GameCommandHandler | None = None
39
+ self.in_menu = True
40
+ self.output_mode = OutputMode.NORMAL
41
+
42
+ async def send_result(self, success: bool, message: str, code: str = "") -> None:
43
+ """Send a result message based on current output mode.
44
+
45
+ Args:
46
+ success: Whether the operation succeeded
47
+ message: Human-readable message
48
+ code: Short code for strict/json mode (e.g., 'PLACED', 'INVALID_MOVE')
49
+ """
50
+ if self.output_mode == OutputMode.JSON:
51
+ response = {"type": "result", "success": success, "code": code, "message": message}
52
+ if self.current_game:
53
+ response["state"] = self.get_game_state_dict()
54
+ await self.send_json_response(**response)
55
+ elif self.output_mode == OutputMode.STRICT:
56
+ prefix = "OK" if success else "ERR"
57
+ await self.send_line(f"{prefix}:{code}" if code else prefix)
58
+ else:
59
+ await self.send_line(message)
60
+
61
+ async def send_game_complete(self) -> None:
62
+ """Send game completion message based on output mode."""
63
+ if not self.current_game:
64
+ return
65
+
66
+ if self.output_mode == OutputMode.JSON:
67
+ await self.send_json_response(
68
+ type="complete",
69
+ success=True,
70
+ game=self.current_game.name,
71
+ moves=self.current_game.moves_made,
72
+ invalid_moves=self.current_game.invalid_moves,
73
+ hints_used=self.current_game.hints_used,
74
+ optimal_steps=self.current_game.optimal_steps,
75
+ )
76
+ elif self.output_mode == OutputMode.STRICT:
77
+ await self.send_line(
78
+ f"COMPLETE:{self.current_game.moves_made}:{self.current_game.invalid_moves}:"
79
+ f"{self.current_game.hints_used}"
80
+ )
81
+ else:
82
+ await self.send_line("\n" + "=" * 50)
83
+ await self.send_line("CONGRATULATIONS! YOU SOLVED IT!")
84
+ await self.send_line("=" * 50)
85
+ await self.send_line(self.current_game.get_stats())
86
+ await self.send_line("\nType 'menu' to play another game.")
87
+ await self.send_line("=" * 50 + "\n")
88
+
89
+ async def send_json_response(self, **kwargs) -> None:
90
+ """Send a JSON-formatted response for RL integration."""
91
+ await self.send_line(json.dumps(kwargs, separators=(",", ":")))
92
+
93
+ def get_game_state_dict(self) -> dict:
94
+ """Get current game state as a dictionary for JSON mode."""
95
+ if not self.current_game:
96
+ return {"error": "no_game"}
97
+
98
+ # Get grid representation
99
+ grid = None
100
+ if hasattr(self.current_game, "grid"):
101
+ grid = self.current_game.grid
102
+
103
+ # Get difficulty profile
104
+ profile = self.current_game.difficulty_profile
105
+ profile_dict = {
106
+ "logic_depth": profile.logic_depth,
107
+ "branching_factor": profile.branching_factor,
108
+ "state_observability": profile.state_observability,
109
+ "constraint_density": profile.constraint_density,
110
+ }
111
+
112
+ return {
113
+ "game": self.current_game.name,
114
+ "difficulty": self.current_game.difficulty.value,
115
+ "seed": self.current_game.seed,
116
+ "moves": self.current_game.moves_made,
117
+ "invalid_moves": self.current_game.invalid_moves,
118
+ "hints_used": self.current_game.hints_used,
119
+ "hints_remaining": self.current_game.hints_remaining,
120
+ "optimal_steps": self.current_game.optimal_steps,
121
+ "is_complete": self.current_game.is_complete(),
122
+ "difficulty_profile": profile_dict,
123
+ "grid": grid,
124
+ }
125
+
126
+ async def show_main_menu(self) -> None:
127
+ """Display the main game selection menu."""
128
+ await self.send_line("\n" + "=" * 50)
129
+ await self.send_line(" WELCOME TO THE PUZZLE ARCADE! ")
130
+ await self.send_line("=" * 50)
131
+ await self.send_line("\nSelect a game:\n")
132
+
133
+ # List available games
134
+ game_list = list(AVAILABLE_GAMES.items())
135
+ for idx, (_game_id, game_class) in enumerate(game_list, 1):
136
+ # Create a temporary instance to get name and description
137
+ temp_game = game_class("easy") # type: ignore[abstract]
138
+ await self.send_line(f" {idx}) {temp_game.name:15s} - {temp_game.description}")
139
+
140
+ await self.send_line("\nCommands:")
141
+ await self.send_line(" <number> [difficulty] [seed] - Select by number")
142
+ await self.send_line(" <name> [difficulty] [seed] - Select by name")
143
+ await self.send_line(" help - Show this menu again")
144
+ await self.send_line(" quit - Exit the server")
145
+ await self.send_line("\nExamples:")
146
+ await self.send_line(" sudoku hard - Random hard Sudoku puzzle")
147
+ await self.send_line(" sudoku hard 12345 - Specific puzzle (shareable)")
148
+ await self.send_line("=" * 50 + "\n")
149
+
150
+ async def show_game_help(self) -> None:
151
+ """Display help for the current game."""
152
+ if not self.current_game:
153
+ await self.send_line("No game in progress. Returning to menu...")
154
+ self.in_menu = True
155
+ await self.show_main_menu()
156
+ return
157
+
158
+ await self.send_line("")
159
+ await self.send_line("=" * 50)
160
+ await self.send_line(f"{self.current_game.name.upper()} - HELP")
161
+ await self.send_line("=" * 50)
162
+
163
+ # Send rules line by line, stripping trailing empty lines
164
+ rules_lines = self.current_game.get_rules().rstrip("\n").split("\n")
165
+ for line in rules_lines:
166
+ await self.send_line(line)
167
+
168
+ await self.send_line("")
169
+
170
+ # Send commands line by line, stripping trailing empty lines
171
+ commands_lines = self.current_game.get_commands().rstrip("\n").split("\n")
172
+ for line in commands_lines:
173
+ await self.send_line(line)
174
+
175
+ await self.send_line("=" * 50)
176
+ await self.send_line("")
177
+
178
+ async def start_game(self, game_id: str, difficulty: str = "easy", seed: int | None = None) -> None:
179
+ """Start a specific game.
180
+
181
+ Args:
182
+ game_id: The game identifier (e.g., 'sudoku', 'kenken')
183
+ difficulty: Game difficulty (easy, medium, hard)
184
+ seed: Optional seed for deterministic puzzle generation
185
+ """
186
+ game_class = AVAILABLE_GAMES.get(game_id.lower())
187
+ if not game_class:
188
+ await self.send_line(f"Unknown game: {game_id}")
189
+ return
190
+
191
+ # Validate difficulty
192
+ valid_difficulties = [d.value for d in DifficultyLevel]
193
+ if difficulty not in valid_difficulties:
194
+ await self.send_line(f"Invalid difficulty. Choose from: {', '.join(valid_difficulties)}")
195
+ difficulty = DifficultyLevel.EASY.value
196
+
197
+ # Create and initialize the game with optional seed
198
+ self.current_game = game_class(difficulty, seed=seed) # type: ignore[abstract]
199
+ await self.current_game.generate_puzzle()
200
+ self.in_menu = False
201
+
202
+ # Set up command handler if available for this game
203
+ handler_class = GAME_COMMAND_HANDLERS.get(game_id.lower())
204
+ if handler_class:
205
+ self.game_handler = handler_class(self.current_game)
206
+ else:
207
+ self.game_handler = None
208
+
209
+ seed_info = f", seed={seed}" if seed is not None else ""
210
+ logger.info(f"Started {game_id} ({difficulty}{seed_info}) for {self.addr}")
211
+
212
+ # Show game header
213
+ await self.send_line("")
214
+ await self.send_line("=" * 50)
215
+ await self.send_line(f"{self.current_game.name.upper()} - {difficulty.upper()} MODE")
216
+ await self.send_line(f"Seed: {self.current_game.seed}")
217
+ await self.send_line("=" * 50)
218
+
219
+ # Send rules line by line, stripping trailing empty lines
220
+ rules_lines = self.current_game.get_rules().rstrip("\n").split("\n")
221
+ for line in rules_lines:
222
+ await self.send_line(line)
223
+
224
+ await self.send_line("")
225
+ await self.send_line("Type 'help' for commands or 'hint' for a clue.")
226
+ await self.send_line("=" * 50)
227
+ await self.send_line("")
228
+
229
+ # Show the initial puzzle
230
+ await self.display_puzzle()
231
+
232
+ async def display_puzzle(self) -> None:
233
+ """Display the current puzzle state."""
234
+ if not self.current_game:
235
+ if self.output_mode == OutputMode.JSON:
236
+ await self.send_json_response(type="error", code="NO_GAME", message="No game in progress")
237
+ elif self.output_mode == OutputMode.STRICT:
238
+ await self.send_line("ERR:NO_GAME")
239
+ else:
240
+ await self.send_line("No game in progress. Type 'menu' to select a game.")
241
+ return
242
+
243
+ if self.output_mode == OutputMode.JSON:
244
+ # JSON mode: full state as JSON for RL agents
245
+ state = self.get_game_state_dict()
246
+ state["type"] = "observation"
247
+ state["grid_display"] = self.current_game.render_grid()
248
+ await self.send_json_response(**state)
249
+ elif self.output_mode == OutputMode.STRICT:
250
+ # Strict mode: terse, machine-verifiable output
251
+ # Format: STATE:<game>:<difficulty>:<seed>:<moves>:<status>
252
+ status = "complete" if self.current_game.is_complete() else "active"
253
+ await self.send_line(
254
+ f"STATE:{self.current_game.name}:{self.current_game.difficulty.value}:"
255
+ f"{self.current_game.seed}:{self.current_game.moves_made}:{status}"
256
+ )
257
+ # Grid as compact lines
258
+ grid_lines = self.current_game.render_grid().rstrip("\n").split("\n")
259
+ for line in grid_lines:
260
+ await self.send_line(line)
261
+ elif self.output_mode == OutputMode.AGENT:
262
+ # Agent-friendly output with clear markers
263
+ await self.send_line("---GAME-START---")
264
+ await self.send_line(f"GAME: {self.current_game.name}")
265
+ await self.send_line(f"DIFFICULTY: {self.current_game.difficulty.value}")
266
+ await self.send_line(f"MOVES: {self.current_game.moves_made}")
267
+ await self.send_line("---GRID-START---")
268
+ grid_lines = self.current_game.render_grid().rstrip("\n").split("\n")
269
+ for line in grid_lines:
270
+ await self.send_line(line)
271
+ await self.send_line("---GRID-END---")
272
+ await self.send_line("---GAME-END---")
273
+ else:
274
+ # Normal/natural human-friendly output
275
+ await self.send_line("")
276
+ await self.send_line("=" * 50)
277
+
278
+ # Send grid line by line, stripping trailing empty lines
279
+ grid_lines = self.current_game.render_grid().rstrip("\n").split("\n")
280
+ for line in grid_lines:
281
+ await self.send_line(line)
282
+
283
+ await self.send_line(self.current_game.get_stats())
284
+ await self.send_line("=" * 50)
285
+ await self.send_line("")
286
+
287
+ async def handle_menu_command(self, command: str) -> None:
288
+ """Process a command when in the main menu.
289
+
290
+ Args:
291
+ command: The command string
292
+ """
293
+ parts = command.strip().lower().split()
294
+ if not parts:
295
+ return
296
+
297
+ cmd = parts[0]
298
+
299
+ # Try to match command to enum
300
+ try:
301
+ cmd_enum = GameCommand(cmd)
302
+ if cmd_enum in (GameCommand.QUIT, GameCommand.EXIT, GameCommand.Q):
303
+ await self.send_line("Thanks for visiting the Puzzle Arcade! Goodbye!")
304
+ await self.end_session()
305
+ return
306
+
307
+ if cmd_enum == GameCommand.HELP:
308
+ await self.show_main_menu()
309
+ return
310
+ except ValueError:
311
+ pass # Not a GameCommand enum, continue to game selection
312
+
313
+ # Helper to parse difficulty and seed from parts
314
+ def parse_game_args(parts: list[str]) -> tuple[str, int | None]:
315
+ """Parse difficulty and optional seed from command parts.
316
+
317
+ Args:
318
+ parts: Command parts after game name/number
319
+
320
+ Returns:
321
+ Tuple of (difficulty, seed or None)
322
+ """
323
+ difficulty = "easy"
324
+ seed = None
325
+
326
+ if len(parts) >= 1:
327
+ difficulty = parts[0]
328
+ if len(parts) >= 2:
329
+ try:
330
+ seed = int(parts[1])
331
+ except ValueError:
332
+ pass # Ignore invalid seed, will generate random
333
+
334
+ return difficulty, seed
335
+
336
+ # Try to parse as game number
337
+ if cmd.isdigit():
338
+ game_idx = int(cmd) - 1
339
+ game_list = list(AVAILABLE_GAMES.keys())
340
+ if 0 <= game_idx < len(game_list):
341
+ game_id = game_list[game_idx]
342
+ difficulty, seed = parse_game_args(parts[1:])
343
+ await self.start_game(game_id, difficulty, seed)
344
+ return
345
+ else:
346
+ await self.send_line(f"Invalid game number. Choose 1-{len(game_list)}.")
347
+ return
348
+
349
+ # Try to parse as game name
350
+ if cmd in AVAILABLE_GAMES:
351
+ difficulty, seed = parse_game_args(parts[1:])
352
+ await self.start_game(cmd, difficulty, seed)
353
+ return
354
+
355
+ await self.send_line("Unknown command. Type 'help' to see available options.")
356
+
357
+ async def handle_game_command(self, command: str) -> None:
358
+ """Process a command when playing a game.
359
+
360
+ Args:
361
+ command: The command string
362
+ """
363
+ if not self.current_game:
364
+ await self.send_line("No game in progress.")
365
+ self.in_menu = True
366
+ await self.show_main_menu()
367
+ return
368
+
369
+ parts = command.strip().lower().split()
370
+ if not parts:
371
+ return
372
+
373
+ cmd = parts[0]
374
+
375
+ # Try to match command to enum
376
+ try:
377
+ cmd_enum = GameCommand(cmd)
378
+ except ValueError:
379
+ await self.send_line(f"Unknown command '{cmd}'. Type 'help' for available commands.")
380
+ return
381
+
382
+ # Global commands
383
+ if cmd_enum in (GameCommand.QUIT, GameCommand.EXIT, GameCommand.Q):
384
+ await self.send_line("Thanks for playing! Goodbye!")
385
+ await self.end_session()
386
+ return
387
+
388
+ if cmd_enum in (GameCommand.MENU, GameCommand.M):
389
+ await self.send_line("Returning to main menu...\n")
390
+ self.current_game = None
391
+ self.game_handler = None
392
+ self.in_menu = True
393
+ await self.show_main_menu()
394
+ return
395
+
396
+ if cmd_enum in (GameCommand.HELP, GameCommand.H):
397
+ await self.show_game_help()
398
+ return
399
+
400
+ if cmd_enum in (GameCommand.SHOW, GameCommand.S):
401
+ await self.display_puzzle()
402
+ return
403
+
404
+ if cmd_enum == GameCommand.MODE:
405
+ if len(parts) != 2:
406
+ await self.send_line("Usage: mode <normal|agent|compact|strict|natural|json>")
407
+ return
408
+
409
+ mode_str = parts[1].lower()
410
+ try:
411
+ new_mode = OutputMode(mode_str)
412
+ self.output_mode = new_mode
413
+ if new_mode == OutputMode.JSON:
414
+ await self.send_json_response(type="mode", mode="json", success=True)
415
+ elif new_mode == OutputMode.STRICT:
416
+ await self.send_line("OK:MODE=strict")
417
+ else:
418
+ await self.send_line(f"Output mode set to: {new_mode.value}")
419
+ except ValueError:
420
+ if self.output_mode == OutputMode.JSON:
421
+ await self.send_json_response(type="error", code="INVALID_MODE", mode=mode_str)
422
+ elif self.output_mode == OutputMode.STRICT:
423
+ await self.send_line(f"ERR:INVALID_MODE:{mode_str}")
424
+ else:
425
+ await self.send_line(
426
+ f"Invalid mode '{mode_str}'. Choose: normal, agent, compact, strict, natural, or json"
427
+ )
428
+ return
429
+
430
+ if cmd_enum == GameCommand.SEED:
431
+ await self.send_line(f"Current puzzle seed: {self.current_game.seed}")
432
+ await self.send_line("To replay this exact puzzle, use:")
433
+ game_name = self.current_game.name.lower().replace(" ", "_")
434
+ await self.send_line(f" {game_name} {self.current_game.difficulty.value} {self.current_game.seed}")
435
+ return
436
+
437
+ if cmd_enum == GameCommand.STATS:
438
+ # Show detailed stats including difficulty profile
439
+ profile = self.current_game.difficulty_profile
440
+ optimal = self.current_game.optimal_steps
441
+
442
+ if self.output_mode == OutputMode.JSON:
443
+ await self.send_json_response(
444
+ type="stats",
445
+ game=self.current_game.name,
446
+ difficulty=self.current_game.difficulty.value,
447
+ seed=self.current_game.seed,
448
+ moves=self.current_game.moves_made,
449
+ invalid_moves=self.current_game.invalid_moves,
450
+ hints_used=self.current_game.hints_used,
451
+ optimal_steps=optimal,
452
+ difficulty_profile={
453
+ "logic_depth": profile.logic_depth,
454
+ "branching_factor": profile.branching_factor,
455
+ "state_observability": profile.state_observability,
456
+ "constraint_density": profile.constraint_density,
457
+ },
458
+ )
459
+ elif self.output_mode == OutputMode.STRICT:
460
+ await self.send_line(
461
+ f"STATS:{self.current_game.moves_made}:{self.current_game.invalid_moves}:"
462
+ f"{self.current_game.hints_used}:{optimal or 0}"
463
+ )
464
+ else:
465
+ await self.send_line("")
466
+ await self.send_line("=" * 50)
467
+ await self.send_line(f"GAME STATISTICS - {self.current_game.name}")
468
+ await self.send_line("=" * 50)
469
+ await self.send_line(f"Difficulty: {self.current_game.difficulty.value}")
470
+ await self.send_line(f"Seed: {self.current_game.seed}")
471
+ await self.send_line("")
472
+ await self.send_line("Progress:")
473
+ await self.send_line(f" Moves made: {self.current_game.moves_made}")
474
+ await self.send_line(f" Invalid attempts: {self.current_game.invalid_moves}")
475
+ await self.send_line(f" Hints used: {self.current_game.hints_used}")
476
+ if optimal:
477
+ efficiency = (
478
+ min(1.0, optimal / max(1, self.current_game.moves_made))
479
+ if self.current_game.moves_made > 0
480
+ else 0
481
+ )
482
+ await self.send_line(f" Optimal steps: {optimal}")
483
+ await self.send_line(f" Current efficiency: {efficiency:.1%}")
484
+ await self.send_line("")
485
+ await self.send_line("Difficulty Profile:")
486
+ await self.send_line(f" Logic depth: {profile.logic_depth}")
487
+ await self.send_line(f" Branching factor: {profile.branching_factor:.1f}")
488
+ await self.send_line(f" State observability: {profile.state_observability:.0%}")
489
+ await self.send_line(f" Constraint density: {profile.constraint_density:.0%}")
490
+ await self.send_line("=" * 50)
491
+ return
492
+
493
+ if cmd_enum == GameCommand.COMPARE:
494
+ # Compare current progress with solver solution
495
+ if not hasattr(self.current_game, "solution"):
496
+ await self.send_result(False, "Comparison not available for this game type.", "COMPARE_UNAVAILABLE")
497
+ return
498
+
499
+ optimal = self.current_game.optimal_steps or 0
500
+ moves = self.current_game.moves_made
501
+ is_complete = self.current_game.is_complete()
502
+
503
+ # Calculate comparison metrics
504
+ if moves > 0 and optimal > 0:
505
+ efficiency = min(1.0, optimal / moves)
506
+ else:
507
+ efficiency = 0.0
508
+
509
+ error_rate = 0.0
510
+ total_actions = moves + self.current_game.invalid_moves
511
+ if total_actions > 0:
512
+ error_rate = self.current_game.invalid_moves / total_actions
513
+
514
+ if self.output_mode == OutputMode.JSON:
515
+ await self.send_json_response(
516
+ type="comparison",
517
+ complete=is_complete,
518
+ your_moves=moves,
519
+ optimal_moves=optimal,
520
+ efficiency=round(efficiency, 3),
521
+ invalid_moves=self.current_game.invalid_moves,
522
+ error_rate=round(error_rate, 3),
523
+ hints_used=self.current_game.hints_used,
524
+ )
525
+ elif self.output_mode == OutputMode.STRICT:
526
+ status = "complete" if is_complete else "incomplete"
527
+ await self.send_line(f"COMPARE:{status}:{moves}:{optimal}:{efficiency:.3f}:{error_rate:.3f}")
528
+ else:
529
+ await self.send_line("")
530
+ await self.send_line("=" * 50)
531
+ await self.send_line("SOLVER COMPARISON")
532
+ await self.send_line("=" * 50)
533
+ await self.send_line(f"Status: {'SOLVED' if is_complete else 'IN PROGRESS'}")
534
+ await self.send_line("")
535
+ await self.send_line("Your Performance:")
536
+ await self.send_line(f" Moves made: {moves}")
537
+ await self.send_line(f" Invalid attempts: {self.current_game.invalid_moves}")
538
+ await self.send_line(f" Hints used: {self.current_game.hints_used}")
539
+ await self.send_line("")
540
+ await self.send_line("Solver Reference:")
541
+ await self.send_line(f" Optimal moves: {optimal}")
542
+ await self.send_line("")
543
+ await self.send_line("Metrics:")
544
+ await self.send_line(f" Efficiency: {efficiency:.1%}")
545
+ await self.send_line(f" Error rate: {error_rate:.1%}")
546
+ if is_complete:
547
+ adjusted = efficiency * (
548
+ 1
549
+ - self.current_game.solver_config.hint_penalty * (self.current_game.hints_used / max(1, moves))
550
+ )
551
+ await self.send_line(f" Adjusted score: {adjusted:.1%}")
552
+ await self.send_line("=" * 50)
553
+ return
554
+
555
+ if cmd_enum == GameCommand.HINT:
556
+ # Check if hints are allowed (via solver config)
557
+ if not self.current_game.record_hint():
558
+ await self.send_result(
559
+ False, "Hints not available (budget exhausted or solver-free mode)", "HINT_DENIED"
560
+ )
561
+ return
562
+
563
+ hint_result = await self.current_game.get_hint()
564
+ if hint_result:
565
+ _, hint_message = hint_result
566
+ if self.output_mode == OutputMode.STRICT:
567
+ await self.send_line(f"HINT:{hint_message}")
568
+ else:
569
+ await self.send_line(f"Hint: {hint_message}")
570
+ else:
571
+ await self.send_result(True, "No hints available. Puzzle is complete!", "HINT_NONE")
572
+ return
573
+
574
+ if cmd_enum == GameCommand.CHECK:
575
+ if self.current_game.is_complete():
576
+ await self.send_game_complete()
577
+ else:
578
+ if self.output_mode == OutputMode.STRICT:
579
+ await self.send_line(f"INCOMPLETE:{self.current_game.moves_made}")
580
+ else:
581
+ await self.send_line("Puzzle not yet complete. Keep going!")
582
+ await self.send_line(self.current_game.get_stats())
583
+ return
584
+
585
+ if cmd_enum == GameCommand.RESET:
586
+ # Reset the game to its initial state
587
+ if hasattr(self.current_game, "initial_grid"):
588
+ self.current_game.grid = [row[:] for row in self.current_game.initial_grid] # type: ignore[attr-defined]
589
+ self.current_game.moves_made = 0
590
+ self.current_game.invalid_moves = 0
591
+ self.current_game.hints_used = 0
592
+ await self.send_result(True, "Puzzle reset to initial state.", "RESET")
593
+ await self.display_puzzle()
594
+ else:
595
+ await self.send_result(False, "Reset not available for this game.", "RESET_UNAVAILABLE")
596
+ return
597
+
598
+ # Delegate to game-specific command handler if available
599
+ if self.game_handler and cmd_enum in self.game_handler.supported_commands:
600
+ result = await self.game_handler.handle_command(cmd_enum, parts[1:])
601
+
602
+ # Track invalid moves
603
+ if not result.result.success:
604
+ self.current_game.invalid_moves += 1
605
+
606
+ # Send result based on output mode
607
+ code = "OK" if result.result.success else "INVALID"
608
+ await self.send_result(result.result.success, result.result.message, code)
609
+
610
+ if result.should_display:
611
+ await self.display_puzzle()
612
+
613
+ if result.is_game_over:
614
+ await self.send_game_complete()
615
+ return
616
+
617
+ # Legacy game-specific commands (for non-migrated games)
618
+ if cmd_enum == GameCommand.PLACE:
619
+ if len(parts) != 4:
620
+ await self.send_result(False, "Usage: place <row> <col> <num>", "USAGE")
621
+ return
622
+
623
+ try:
624
+ row = int(parts[1])
625
+ col = int(parts[2])
626
+ num = int(parts[3])
627
+
628
+ result = await self.current_game.validate_move(row, col, num)
629
+
630
+ if not result.success:
631
+ self.current_game.invalid_moves += 1
632
+
633
+ await self.send_result(result.success, result.message, "PLACED" if result.success else "INVALID_MOVE")
634
+
635
+ if result.success:
636
+ await self.display_puzzle()
637
+
638
+ if self.current_game.is_complete():
639
+ await self.send_game_complete()
640
+
641
+ except ValueError:
642
+ self.current_game.invalid_moves += 1
643
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
644
+ return
645
+
646
+ if cmd_enum == GameCommand.CLEAR:
647
+ if len(parts) != 3:
648
+ await self.send_result(False, "Usage: clear <row> <col>", "USAGE")
649
+ return
650
+
651
+ try:
652
+ row = int(parts[1])
653
+ col = int(parts[2])
654
+
655
+ result = await self.current_game.validate_move(row, col, 0)
656
+
657
+ if not result.success:
658
+ self.current_game.invalid_moves += 1
659
+
660
+ await self.send_result(result.success, result.message, "CLEARED" if result.success else "INVALID_CLEAR")
661
+
662
+ if result.success:
663
+ await self.display_puzzle()
664
+
665
+ except ValueError:
666
+ self.current_game.invalid_moves += 1
667
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
668
+ return
669
+
670
+ if cmd_enum == GameCommand.SOLVE:
671
+ # Copy solution to grid (game-specific)
672
+ if hasattr(self.current_game, "solution"):
673
+ self.current_game.grid = [row[:] for row in self.current_game.solution] # type: ignore[attr-defined]
674
+ if self.output_mode == OutputMode.STRICT:
675
+ await self.send_line("OK:SOLVED")
676
+ else:
677
+ await self.send_line("\nShowing solution...\n")
678
+ await self.display_puzzle()
679
+ if self.output_mode != OutputMode.STRICT:
680
+ await self.send_line("Type 'menu' to play another game.")
681
+ else:
682
+ await self.send_result(False, "Solve not implemented for this game.", "SOLVE_UNAVAILABLE")
683
+ return
684
+
685
+ # Lights Out specific command
686
+ if cmd_enum == GameCommand.PRESS:
687
+ if len(parts) != 3:
688
+ await self.send_result(False, "Usage: press <row> <col>", "USAGE")
689
+ return
690
+
691
+ try:
692
+ row = int(parts[1])
693
+ col = int(parts[2])
694
+
695
+ result = await self.current_game.validate_move(row, col)
696
+
697
+ if not result.success:
698
+ self.current_game.invalid_moves += 1
699
+
700
+ await self.send_result(result.success, result.message, "PRESSED" if result.success else "INVALID_PRESS")
701
+
702
+ if result.success:
703
+ await self.display_puzzle()
704
+
705
+ if self.current_game.is_complete():
706
+ await self.send_game_complete()
707
+
708
+ except ValueError:
709
+ self.current_game.invalid_moves += 1
710
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
711
+ return
712
+
713
+ # Logic Grid specific commands
714
+ if cmd_enum == GameCommand.CONNECT:
715
+ if len(parts) != 5:
716
+ await self.send_result(False, "Usage: connect <cat1> <val1> <cat2> <val2>", "USAGE")
717
+ return
718
+
719
+ cat1, val1, cat2, val2 = parts[1], parts[2], parts[3], parts[4]
720
+ result = await self.current_game.validate_move(cat1, val1, cat2, val2, True)
721
+
722
+ if not result.success:
723
+ self.current_game.invalid_moves += 1
724
+
725
+ await self.send_result(result.success, result.message, "CONNECTED" if result.success else "INVALID_CONNECT")
726
+ if result.success:
727
+ await self.display_puzzle()
728
+ if self.current_game.is_complete():
729
+ await self.send_game_complete()
730
+ return
731
+
732
+ if cmd_enum == GameCommand.EXCLUDE:
733
+ if len(parts) != 5:
734
+ await self.send_result(False, "Usage: exclude <cat1> <val1> <cat2> <val2>", "USAGE")
735
+ return
736
+
737
+ cat1, val1, cat2, val2 = parts[1], parts[2], parts[3], parts[4]
738
+ result = await self.current_game.validate_move(cat1, val1, cat2, val2, False)
739
+
740
+ if not result.success:
741
+ self.current_game.invalid_moves += 1
742
+
743
+ await self.send_result(result.success, result.message, "EXCLUDED" if result.success else "INVALID_EXCLUDE")
744
+ if result.success:
745
+ await self.display_puzzle()
746
+ if self.current_game.is_complete():
747
+ await self.send_game_complete()
748
+ return
749
+
750
+ # Minesweeper commands
751
+ if cmd_enum == GameCommand.REVEAL:
752
+ if len(parts) != 3:
753
+ await self.send_result(False, "Usage: reveal <row> <col>", "USAGE")
754
+ return
755
+
756
+ try:
757
+ row = int(parts[1])
758
+ col = int(parts[2])
759
+
760
+ result = await self.current_game.validate_move("reveal", row, col)
761
+
762
+ if not result.success:
763
+ self.current_game.invalid_moves += 1
764
+
765
+ await self.send_result(
766
+ result.success, result.message, "REVEALED" if result.success else "INVALID_REVEAL"
767
+ )
768
+
769
+ if result.success:
770
+ await self.display_puzzle()
771
+
772
+ if result.game_over:
773
+ if self.current_game.is_complete():
774
+ await self.send_game_complete()
775
+ else:
776
+ if self.output_mode == OutputMode.STRICT:
777
+ await self.send_line(f"GAMEOVER:MINE:{self.current_game.moves_made}")
778
+ else:
779
+ await self.send_line("\n" + "=" * 50)
780
+ await self.send_line("GAME OVER! You hit a mine!")
781
+ await self.send_line("=" * 50)
782
+ await self.send_line("\nType 'menu' to play another game.")
783
+ await self.send_line("=" * 50 + "\n")
784
+
785
+ except ValueError:
786
+ self.current_game.invalid_moves += 1
787
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
788
+ return
789
+
790
+ if cmd_enum == GameCommand.FLAG:
791
+ if len(parts) != 3:
792
+ await self.send_result(False, "Usage: flag <row> <col>", "USAGE")
793
+ return
794
+
795
+ try:
796
+ row = int(parts[1])
797
+ col = int(parts[2])
798
+
799
+ result = await self.current_game.validate_move("flag", row, col)
800
+
801
+ if not result.success:
802
+ self.current_game.invalid_moves += 1
803
+
804
+ await self.send_result(result.success, result.message, "FLAGGED" if result.success else "INVALID_FLAG")
805
+
806
+ if result.success:
807
+ await self.display_puzzle()
808
+
809
+ except ValueError:
810
+ self.current_game.invalid_moves += 1
811
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
812
+ return
813
+
814
+ # Slitherlink command
815
+ if cmd_enum == GameCommand.SET:
816
+ if len(parts) != 5:
817
+ await self.send_result(False, "Usage: set <h|v> <row> <col> <state>", "USAGE")
818
+ return
819
+
820
+ try:
821
+ edge_type = parts[1].lower()
822
+ row = int(parts[2])
823
+ col = int(parts[3])
824
+ state = int(parts[4])
825
+
826
+ result = await self.current_game.validate_move(edge_type, row, col, state)
827
+
828
+ if not result.success:
829
+ self.current_game.invalid_moves += 1
830
+
831
+ await self.send_result(result.success, result.message, "SET" if result.success else "INVALID_SET")
832
+
833
+ if result.success:
834
+ await self.display_puzzle()
835
+
836
+ if self.current_game.is_complete():
837
+ await self.send_game_complete()
838
+
839
+ except ValueError:
840
+ self.current_game.invalid_moves += 1
841
+ await self.send_result(False, "Invalid input. Use numbers only for row, col, state.", "PARSE_ERROR")
842
+ return
843
+
844
+ # Mastermind command
845
+ if cmd_enum == GameCommand.GUESS:
846
+ if len(parts) < 2:
847
+ await self.send_result(False, "Usage: guess <color1> <color2> ... <colorN>", "USAGE")
848
+ return
849
+
850
+ try:
851
+ guess = [int(p) for p in parts[1:]]
852
+
853
+ result = await self.current_game.validate_move(*guess)
854
+
855
+ if not result.success:
856
+ self.current_game.invalid_moves += 1
857
+
858
+ await self.send_result(result.success, result.message, "GUESSED" if result.success else "INVALID_GUESS")
859
+
860
+ if result.success:
861
+ await self.display_puzzle()
862
+
863
+ if self.current_game.is_complete():
864
+ await self.send_game_complete()
865
+
866
+ if result.game_over and not self.current_game.is_complete():
867
+ if self.output_mode == OutputMode.STRICT:
868
+ await self.send_line(f"GAMEOVER:OUT_OF_GUESSES:{self.current_game.moves_made}")
869
+ else:
870
+ await self.send_line("\n" + "=" * 50)
871
+ await self.send_line("GAME OVER! Out of guesses!")
872
+ await self.send_line("=" * 50)
873
+ await self.send_line("\nType 'menu' to play another game.")
874
+ await self.send_line("=" * 50 + "\n")
875
+
876
+ except ValueError:
877
+ self.current_game.invalid_moves += 1
878
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
879
+ return
880
+
881
+ # Knapsack commands
882
+ if cmd_enum == GameCommand.SELECT:
883
+ if len(parts) != 2:
884
+ await self.send_result(False, "Usage: select <item_number>", "USAGE")
885
+ return
886
+
887
+ try:
888
+ item_index = int(parts[1])
889
+
890
+ result = await self.current_game.validate_move("select", item_index)
891
+
892
+ if not result.success:
893
+ self.current_game.invalid_moves += 1
894
+
895
+ await self.send_result(
896
+ result.success, result.message, "SELECTED" if result.success else "INVALID_SELECT"
897
+ )
898
+
899
+ if result.success:
900
+ await self.display_puzzle()
901
+
902
+ except ValueError:
903
+ self.current_game.invalid_moves += 1
904
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
905
+ return
906
+
907
+ if cmd_enum == GameCommand.DESELECT:
908
+ if len(parts) != 2:
909
+ await self.send_result(False, "Usage: deselect <item_number>", "USAGE")
910
+ return
911
+
912
+ try:
913
+ item_index = int(parts[1])
914
+
915
+ result = await self.current_game.validate_move("deselect", item_index)
916
+
917
+ if not result.success:
918
+ self.current_game.invalid_moves += 1
919
+
920
+ await self.send_result(
921
+ result.success, result.message, "DESELECTED" if result.success else "INVALID_DESELECT"
922
+ )
923
+
924
+ if result.success:
925
+ await self.display_puzzle()
926
+
927
+ except ValueError:
928
+ self.current_game.invalid_moves += 1
929
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
930
+ return
931
+
932
+ # Nurikabe command
933
+ if cmd_enum == GameCommand.MARK:
934
+ if len(parts) != 4:
935
+ await self.send_result(False, "Usage: mark <row> <col> <white|black|clear>", "USAGE")
936
+ return
937
+
938
+ try:
939
+ row = int(parts[1])
940
+ col = int(parts[2])
941
+ color = parts[3].lower()
942
+
943
+ result = await self.current_game.validate_move(row, col, color)
944
+
945
+ if not result.success:
946
+ self.current_game.invalid_moves += 1
947
+
948
+ await self.send_result(result.success, result.message, "MARKED" if result.success else "INVALID_MARK")
949
+
950
+ if result.success:
951
+ await self.display_puzzle()
952
+
953
+ if self.current_game.is_complete():
954
+ await self.send_game_complete()
955
+
956
+ except ValueError:
957
+ self.current_game.invalid_moves += 1
958
+ await self.send_result(False, "Invalid input. Row and col must be numbers.", "PARSE_ERROR")
959
+ return
960
+
961
+ # Hitori command
962
+ if cmd_enum == GameCommand.SHADE:
963
+ if len(parts) != 3:
964
+ await self.send_result(False, "Usage: shade <row> <col>", "USAGE")
965
+ return
966
+
967
+ try:
968
+ row = int(parts[1])
969
+ col = int(parts[2])
970
+
971
+ result = await self.current_game.validate_move(row, col, "shade")
972
+
973
+ if not result.success:
974
+ self.current_game.invalid_moves += 1
975
+
976
+ await self.send_result(result.success, result.message, "SHADED" if result.success else "INVALID_SHADE")
977
+
978
+ if result.success:
979
+ await self.display_puzzle()
980
+
981
+ if self.current_game.is_complete():
982
+ await self.send_game_complete()
983
+
984
+ except ValueError:
985
+ self.current_game.invalid_moves += 1
986
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
987
+ return
988
+
989
+ # Bridges command
990
+ if cmd_enum == GameCommand.BRIDGE:
991
+ if len(parts) != 6:
992
+ await self.send_result(False, "Usage: bridge <r1> <c1> <r2> <c2> <count>", "USAGE")
993
+ return
994
+
995
+ try:
996
+ r1 = int(parts[1])
997
+ c1 = int(parts[2])
998
+ r2 = int(parts[3])
999
+ c2 = int(parts[4])
1000
+ count = int(parts[5])
1001
+
1002
+ result = await self.current_game.validate_move(r1, c1, r2, c2, count)
1003
+
1004
+ if not result.success:
1005
+ self.current_game.invalid_moves += 1
1006
+
1007
+ await self.send_result(
1008
+ result.success, result.message, "BRIDGED" if result.success else "INVALID_BRIDGE"
1009
+ )
1010
+
1011
+ if result.success:
1012
+ await self.display_puzzle()
1013
+
1014
+ if self.current_game.is_complete():
1015
+ await self.send_game_complete()
1016
+
1017
+ except ValueError:
1018
+ self.current_game.invalid_moves += 1
1019
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
1020
+ return
1021
+
1022
+ # Sokoban command
1023
+ if cmd_enum == GameCommand.MOVE:
1024
+ if len(parts) != 2:
1025
+ await self.send_result(False, "Usage: move <direction>", "USAGE")
1026
+ return
1027
+
1028
+ direction = parts[1].lower()
1029
+
1030
+ result = await self.current_game.validate_move(direction)
1031
+
1032
+ if not result.success:
1033
+ self.current_game.invalid_moves += 1
1034
+
1035
+ await self.send_result(result.success, result.message, "MOVED" if result.success else "INVALID_MOVE")
1036
+
1037
+ if result.success:
1038
+ await self.display_puzzle()
1039
+
1040
+ if self.current_game.is_complete():
1041
+ await self.send_game_complete()
1042
+
1043
+ return
1044
+
1045
+ # Scheduler commands
1046
+ if cmd_enum == GameCommand.ASSIGN:
1047
+ if len(parts) != 4:
1048
+ await self.send_result(False, "Usage: assign <task_id> <worker_id> <start_time>", "USAGE")
1049
+ return
1050
+
1051
+ try:
1052
+ task_id = int(parts[1])
1053
+ worker_id = int(parts[2])
1054
+ start_time = int(parts[3])
1055
+
1056
+ result = await self.current_game.validate_move(task_id, worker_id, start_time)
1057
+
1058
+ if not result.success:
1059
+ self.current_game.invalid_moves += 1
1060
+
1061
+ await self.send_result(
1062
+ result.success, result.message, "ASSIGNED" if result.success else "INVALID_ASSIGN"
1063
+ )
1064
+
1065
+ if result.success:
1066
+ await self.display_puzzle()
1067
+ if self.current_game.is_complete():
1068
+ await self.send_game_complete()
1069
+
1070
+ except ValueError:
1071
+ self.current_game.invalid_moves += 1
1072
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
1073
+ return
1074
+
1075
+ if cmd_enum == GameCommand.UNASSIGN:
1076
+ if len(parts) != 2:
1077
+ await self.send_result(False, "Usage: unassign <task_id>", "USAGE")
1078
+ return
1079
+
1080
+ try:
1081
+ task_id = int(parts[1])
1082
+
1083
+ result = await self.current_game.validate_move(task_id, 0, -1)
1084
+
1085
+ if not result.success:
1086
+ self.current_game.invalid_moves += 1
1087
+
1088
+ await self.send_result(
1089
+ result.success, result.message, "UNASSIGNED" if result.success else "INVALID_UNASSIGN"
1090
+ )
1091
+
1092
+ if result.success:
1093
+ await self.display_puzzle()
1094
+
1095
+ except ValueError:
1096
+ self.current_game.invalid_moves += 1
1097
+ await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
1098
+ return
1099
+
1100
+ await self.send_result(False, "Unknown command. Type 'help' for available commands.", "UNKNOWN_CMD")
1101
+
1102
+ async def on_command_submitted(self, command: str) -> None:
1103
+ """Process a command from the player.
1104
+
1105
+ Args:
1106
+ command: The command string
1107
+ """
1108
+ if self.in_menu:
1109
+ await self.handle_menu_command(command)
1110
+ else:
1111
+ await self.handle_game_command(command)
1112
+
1113
+ async def send_welcome(self) -> None:
1114
+ """Send a welcome message to the player."""
1115
+ await self.show_main_menu()
1116
+
1117
+ async def process_line(self, line: str) -> bool:
1118
+ """Process a line of input from the client.
1119
+
1120
+ Args:
1121
+ line: The line to process
1122
+
1123
+ Returns:
1124
+ True to continue processing, False to terminate
1125
+ """
1126
+ logger.debug(f"ArcadeHandler process_line => {line!r}")
1127
+
1128
+ # Check for exit commands
1129
+ if line.lower() in ["quit", "exit", "q"]:
1130
+ await self.send_line("Thanks for visiting the Puzzle Arcade! Goodbye!")
1131
+ await self.end_session()
1132
+ return False
1133
+
1134
+ # Process the command
1135
+ await self.on_command_submitted(line)
1136
+
1137
+ return True
1138
+
1139
+
1140
+ async def main():
1141
+ """Main entry point for the Puzzle Arcade server."""
1142
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
1143
+
1144
+ host, port = "0.0.0.0", 8023
1145
+ server = TelnetServer(host, port, ArcadeHandler)
1146
+
1147
+ try:
1148
+ logger.info(f"Starting Puzzle Arcade Server on {host}:{port}")
1149
+ await server.start_server()
1150
+ except KeyboardInterrupt:
1151
+ logger.info("Server shutdown initiated by user.")
1152
+ except Exception as e:
1153
+ logger.error(f"Error running server: {e}")
1154
+ finally:
1155
+ logger.info("Server has shut down.")
1156
+
1157
+
1158
+ def run_server():
1159
+ """CLI entry point for running the server."""
1160
+ try:
1161
+ asyncio.run(main())
1162
+ except KeyboardInterrupt:
1163
+ logger.info("Received keyboard interrupt.")
1164
+ except Exception as e:
1165
+ logger.error(f"Unhandled exception: {e}")
1166
+ finally:
1167
+ logger.info("Server process exiting.")
1168
+
1169
+
1170
+ if __name__ == "__main__":
1171
+ run_server()