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.
- chuk_puzzles_gym/__init__.py +19 -0
- chuk_puzzles_gym/constants.py +9 -0
- chuk_puzzles_gym/eval.py +763 -0
- chuk_puzzles_gym/export/__init__.py +20 -0
- chuk_puzzles_gym/export/dataset.py +376 -0
- chuk_puzzles_gym/games/__init__.py +94 -0
- chuk_puzzles_gym/games/_base/__init__.py +6 -0
- chuk_puzzles_gym/games/_base/commands.py +91 -0
- chuk_puzzles_gym/games/_base/game.py +337 -0
- chuk_puzzles_gym/games/binary/__init__.py +6 -0
- chuk_puzzles_gym/games/binary/config.py +23 -0
- chuk_puzzles_gym/games/binary/game.py +434 -0
- chuk_puzzles_gym/games/bridges/__init__.py +6 -0
- chuk_puzzles_gym/games/bridges/config.py +24 -0
- chuk_puzzles_gym/games/bridges/game.py +489 -0
- chuk_puzzles_gym/games/einstein/__init__.py +6 -0
- chuk_puzzles_gym/games/einstein/config.py +23 -0
- chuk_puzzles_gym/games/einstein/constants.py +13 -0
- chuk_puzzles_gym/games/einstein/game.py +366 -0
- chuk_puzzles_gym/games/einstein/models.py +35 -0
- chuk_puzzles_gym/games/fillomino/__init__.py +6 -0
- chuk_puzzles_gym/games/fillomino/config.py +24 -0
- chuk_puzzles_gym/games/fillomino/game.py +516 -0
- chuk_puzzles_gym/games/futoshiki/__init__.py +6 -0
- chuk_puzzles_gym/games/futoshiki/config.py +23 -0
- chuk_puzzles_gym/games/futoshiki/game.py +391 -0
- chuk_puzzles_gym/games/hidato/__init__.py +6 -0
- chuk_puzzles_gym/games/hidato/config.py +24 -0
- chuk_puzzles_gym/games/hidato/game.py +403 -0
- chuk_puzzles_gym/games/hitori/__init__.py +6 -0
- chuk_puzzles_gym/games/hitori/config.py +23 -0
- chuk_puzzles_gym/games/hitori/game.py +451 -0
- chuk_puzzles_gym/games/kakuro/__init__.py +6 -0
- chuk_puzzles_gym/games/kakuro/config.py +24 -0
- chuk_puzzles_gym/games/kakuro/game.py +399 -0
- chuk_puzzles_gym/games/kenken/__init__.py +6 -0
- chuk_puzzles_gym/games/kenken/config.py +24 -0
- chuk_puzzles_gym/games/kenken/enums.py +13 -0
- chuk_puzzles_gym/games/kenken/game.py +486 -0
- chuk_puzzles_gym/games/kenken/models.py +15 -0
- chuk_puzzles_gym/games/killer_sudoku/__init__.py +6 -0
- chuk_puzzles_gym/games/killer_sudoku/config.py +23 -0
- chuk_puzzles_gym/games/killer_sudoku/game.py +502 -0
- chuk_puzzles_gym/games/killer_sudoku/models.py +15 -0
- chuk_puzzles_gym/games/knapsack/__init__.py +6 -0
- chuk_puzzles_gym/games/knapsack/config.py +24 -0
- chuk_puzzles_gym/games/knapsack/enums.py +10 -0
- chuk_puzzles_gym/games/knapsack/game.py +340 -0
- chuk_puzzles_gym/games/knapsack/models.py +13 -0
- chuk_puzzles_gym/games/lights_out/__init__.py +6 -0
- chuk_puzzles_gym/games/lights_out/config.py +24 -0
- chuk_puzzles_gym/games/lights_out/game.py +249 -0
- chuk_puzzles_gym/games/logic_grid/__init__.py +6 -0
- chuk_puzzles_gym/games/logic_grid/config.py +24 -0
- chuk_puzzles_gym/games/logic_grid/constants.py +12 -0
- chuk_puzzles_gym/games/logic_grid/game.py +333 -0
- chuk_puzzles_gym/games/logic_grid/models.py +24 -0
- chuk_puzzles_gym/games/mastermind/__init__.py +6 -0
- chuk_puzzles_gym/games/mastermind/config.py +25 -0
- chuk_puzzles_gym/games/mastermind/game.py +297 -0
- chuk_puzzles_gym/games/minesweeper/__init__.py +6 -0
- chuk_puzzles_gym/games/minesweeper/config.py +24 -0
- chuk_puzzles_gym/games/minesweeper/enums.py +12 -0
- chuk_puzzles_gym/games/minesweeper/game.py +432 -0
- chuk_puzzles_gym/games/nonogram/__init__.py +6 -0
- chuk_puzzles_gym/games/nonogram/config.py +23 -0
- chuk_puzzles_gym/games/nonogram/game.py +296 -0
- chuk_puzzles_gym/games/nurikabe/__init__.py +6 -0
- chuk_puzzles_gym/games/nurikabe/config.py +24 -0
- chuk_puzzles_gym/games/nurikabe/enums.py +14 -0
- chuk_puzzles_gym/games/nurikabe/game.py +586 -0
- chuk_puzzles_gym/games/scheduler/__init__.py +6 -0
- chuk_puzzles_gym/games/scheduler/config.py +25 -0
- chuk_puzzles_gym/games/scheduler/constants.py +15 -0
- chuk_puzzles_gym/games/scheduler/enums.py +10 -0
- chuk_puzzles_gym/games/scheduler/game.py +431 -0
- chuk_puzzles_gym/games/scheduler/models.py +14 -0
- chuk_puzzles_gym/games/shikaku/__init__.py +6 -0
- chuk_puzzles_gym/games/shikaku/config.py +24 -0
- chuk_puzzles_gym/games/shikaku/game.py +419 -0
- chuk_puzzles_gym/games/slitherlink/__init__.py +6 -0
- chuk_puzzles_gym/games/slitherlink/config.py +23 -0
- chuk_puzzles_gym/games/slitherlink/game.py +386 -0
- chuk_puzzles_gym/games/sokoban/__init__.py +6 -0
- chuk_puzzles_gym/games/sokoban/config.py +24 -0
- chuk_puzzles_gym/games/sokoban/game.py +671 -0
- chuk_puzzles_gym/games/star_battle/__init__.py +6 -0
- chuk_puzzles_gym/games/star_battle/config.py +24 -0
- chuk_puzzles_gym/games/star_battle/game.py +390 -0
- chuk_puzzles_gym/games/sudoku/__init__.py +7 -0
- chuk_puzzles_gym/games/sudoku/commands.py +96 -0
- chuk_puzzles_gym/games/sudoku/config.py +22 -0
- chuk_puzzles_gym/games/sudoku/game.py +328 -0
- chuk_puzzles_gym/games/tents/__init__.py +6 -0
- chuk_puzzles_gym/games/tents/config.py +24 -0
- chuk_puzzles_gym/games/tents/game.py +416 -0
- chuk_puzzles_gym/gym_env.py +465 -0
- chuk_puzzles_gym/models/__init__.py +47 -0
- chuk_puzzles_gym/models/base.py +30 -0
- chuk_puzzles_gym/models/config.py +11 -0
- chuk_puzzles_gym/models/enums.py +104 -0
- chuk_puzzles_gym/models/evaluation.py +487 -0
- chuk_puzzles_gym/models/games.py +12 -0
- chuk_puzzles_gym/server.py +1171 -0
- chuk_puzzles_gym/trace/__init__.py +10 -0
- chuk_puzzles_gym/trace/generator.py +726 -0
- chuk_puzzles_gym/utils/__init__.py +4 -0
- chuk_puzzles_gym-0.9.dist-info/METADATA +1471 -0
- chuk_puzzles_gym-0.9.dist-info/RECORD +112 -0
- chuk_puzzles_gym-0.9.dist-info/WHEEL +5 -0
- chuk_puzzles_gym-0.9.dist-info/entry_points.txt +4 -0
- 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()
|