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,416 @@
1
+ """Tents and Trees puzzle game implementation."""
2
+
3
+ from typing import Any
4
+
5
+ from ...models import DifficultyProfile, MoveResult
6
+ from .._base import PuzzleGame
7
+ from .config import TentsConfig
8
+
9
+
10
+ class TentsGame(PuzzleGame):
11
+ """Tents and Trees puzzle game.
12
+
13
+ Place tents next to trees such that:
14
+ - Each tree has exactly one tent adjacent to it (horizontally or vertically)
15
+ - Each tent is adjacent to exactly one tree
16
+ - Tents cannot touch each other (not even diagonally)
17
+ - Row and column counts show how many tents are in each row/column
18
+ """
19
+
20
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
21
+ """Initialize a new Tents game.
22
+
23
+ Args:
24
+ difficulty: Game difficulty level (easy=6x6, medium=8x8, hard=10x10)
25
+ """
26
+ super().__init__(difficulty, seed, **kwargs)
27
+
28
+ # Use pydantic config based on difficulty
29
+ self.config = TentsConfig.from_difficulty(self.difficulty)
30
+ self.size = self.config.size
31
+
32
+ # Grid: 0 = empty, 1 = tree, 2 = tent (player-placed)
33
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
34
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
35
+
36
+ # Trees are fixed at puzzle generation
37
+ self.trees = [[False for _ in range(self.size)] for _ in range(self.size)]
38
+
39
+ # Row and column counts
40
+ self.row_counts: list[int] = []
41
+ self.col_counts: list[int] = []
42
+
43
+ @property
44
+ def name(self) -> str:
45
+ """The display name of this puzzle type."""
46
+ return "Tents and Trees"
47
+
48
+ @property
49
+ def description(self) -> str:
50
+ """A one-line description of this puzzle type."""
51
+ return "Match tents to trees while avoiding adjacency conflicts"
52
+
53
+ @property
54
+ def constraint_types(self) -> list[str]:
55
+ """Constraint types demonstrated by this puzzle."""
56
+ return ["bipartite_matching", "adjacency_avoidance", "counting", "one_to_one_correspondence"]
57
+
58
+ @property
59
+ def business_analogies(self) -> list[str]:
60
+ """Business problems this puzzle models."""
61
+ return ["resource_pairing", "spatial_allocation", "conflict_avoidance", "matching_with_constraints"]
62
+
63
+ @property
64
+ def complexity_profile(self) -> dict[str, str]:
65
+ """Complexity profile of this puzzle."""
66
+ return {"reasoning_type": "deductive", "search_space": "medium", "constraint_density": "moderate"}
67
+
68
+ @property
69
+ def optimal_steps(self) -> int | None:
70
+ """Minimum steps = tents to place (equals trees)."""
71
+ if not hasattr(self, "solution") or not self.solution:
72
+ return None
73
+ # Solution uses: 0=empty, 1=tent, 2=tree. Count tents only.
74
+ return sum(1 for row in self.solution for cell in row if cell == 1)
75
+
76
+ @property
77
+ def difficulty_profile(self) -> "DifficultyProfile":
78
+ """Difficulty characteristics for Tents and Trees."""
79
+ from ...models import DifficultyLevel
80
+
81
+ logic_depth = {
82
+ DifficultyLevel.EASY.value: 2,
83
+ DifficultyLevel.MEDIUM.value: 4,
84
+ DifficultyLevel.HARD.value: 5,
85
+ }.get(self.difficulty.value, 3)
86
+ return DifficultyProfile(
87
+ logic_depth=logic_depth,
88
+ branching_factor=4.0, # 4 adjacent positions
89
+ state_observability=1.0,
90
+ constraint_density=0.5,
91
+ )
92
+
93
+ def _get_adjacent(self, row: int, col: int) -> list[tuple[int, int]]:
94
+ """Get orthogonally adjacent cells (no diagonals).
95
+
96
+ Args:
97
+ row: Row index
98
+ col: Column index
99
+
100
+ Returns:
101
+ List of (row, col) tuples for valid adjacent cells
102
+ """
103
+ adjacent = []
104
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
105
+ nr, nc = row + dr, col + dc
106
+ if 0 <= nr < self.size and 0 <= nc < self.size:
107
+ adjacent.append((nr, nc))
108
+ return adjacent
109
+
110
+ def _get_all_adjacent(self, row: int, col: int) -> list[tuple[int, int]]:
111
+ """Get all adjacent cells including diagonals.
112
+
113
+ Args:
114
+ row: Row index
115
+ col: Column index
116
+
117
+ Returns:
118
+ List of (row, col) tuples for all adjacent cells
119
+ """
120
+ adjacent = []
121
+ for dr in [-1, 0, 1]:
122
+ for dc in [-1, 0, 1]:
123
+ if dr == 0 and dc == 0:
124
+ continue
125
+ nr, nc = row + dr, col + dc
126
+ if 0 <= nr < self.size and 0 <= nc < self.size:
127
+ adjacent.append((nr, nc))
128
+ return adjacent
129
+
130
+ async def generate_puzzle(self) -> None:
131
+ """Generate a new Tents and Trees puzzle."""
132
+ # Reset grids
133
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
134
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
135
+ self.trees = [[False for _ in range(self.size)] for _ in range(self.size)]
136
+
137
+ # Place trees and tents
138
+ max_attempts = 100
139
+ for _ in range(max_attempts):
140
+ if self._try_generate():
141
+ break
142
+
143
+ # Calculate row and column counts from solution
144
+ self.row_counts = [sum(1 for c in range(self.size) if self.solution[r][c] == 2) for r in range(self.size)]
145
+ self.col_counts = [sum(1 for r in range(self.size) if self.solution[r][c] == 2) for c in range(self.size)]
146
+
147
+ # Copy trees to player grid
148
+ for r in range(self.size):
149
+ for c in range(self.size):
150
+ if self.trees[r][c]:
151
+ self.grid[r][c] = 1
152
+
153
+ self.moves_made = 0
154
+ self.game_started = True
155
+
156
+ def _try_generate(self) -> bool:
157
+ """Try to generate a valid puzzle.
158
+
159
+ Returns:
160
+ True if generation succeeded
161
+ """
162
+ # Reset
163
+ self.trees = [[False for _ in range(self.size)] for _ in range(self.size)]
164
+ tent_solution = [[False for _ in range(self.size)] for _ in range(self.size)]
165
+
166
+ # Number of tree-tent pairs based on difficulty
167
+ num_pairs = self.config.num_trees
168
+
169
+ # Place tree-tent pairs
170
+ placed_pairs = 0
171
+ attempts = 0
172
+ max_attempts = 500
173
+
174
+ while placed_pairs < num_pairs and attempts < max_attempts:
175
+ attempts += 1
176
+
177
+ # Pick random position for tree
178
+ tree_r = self._rng.randint(0, self.size - 1)
179
+ tree_c = self._rng.randint(0, self.size - 1)
180
+
181
+ # Skip if already has tree or tent
182
+ if self.trees[tree_r][tree_c] or tent_solution[tree_r][tree_c]:
183
+ continue
184
+
185
+ # Skip if this position is adjacent to an existing tree or tent
186
+ # (would create ambiguous tree-tent associations)
187
+ tree_adjacent = self._get_adjacent(tree_r, tree_c)
188
+ if any(self.trees[ar][ac] for ar, ac in tree_adjacent):
189
+ continue
190
+ if any(tent_solution[ar][ac] for ar, ac in tree_adjacent):
191
+ continue
192
+
193
+ # Find valid tent positions (adjacent, not touching other tents)
194
+ valid_tent_positions = []
195
+
196
+ for tent_r, tent_c in tree_adjacent:
197
+ # Check if position is empty
198
+ if self.trees[tent_r][tent_c] or tent_solution[tent_r][tent_c]:
199
+ continue
200
+
201
+ # Check if tent would touch another tent (including diagonally)
202
+ all_adj = self._get_all_adjacent(tent_r, tent_c)
203
+ if any(tent_solution[ar][ac] for ar, ac in all_adj):
204
+ continue
205
+
206
+ # Check if this tent position would be adjacent to another tree
207
+ # (would create ambiguous tent-tree associations)
208
+ tent_adj = self._get_adjacent(tent_r, tent_c)
209
+ other_trees = sum(1 for ar, ac in tent_adj if self.trees[ar][ac])
210
+ if other_trees > 0:
211
+ continue
212
+
213
+ valid_tent_positions.append((tent_r, tent_c))
214
+
215
+ if valid_tent_positions:
216
+ # Place tree and tent
217
+ tent_r, tent_c = self._rng.choice(valid_tent_positions)
218
+ self.trees[tree_r][tree_c] = True
219
+ tent_solution[tent_r][tent_c] = True
220
+ placed_pairs += 1
221
+
222
+ # Transfer to solution grid
223
+ for r in range(self.size):
224
+ for c in range(self.size):
225
+ if self.trees[r][c]:
226
+ self.solution[r][c] = 1
227
+ elif tent_solution[r][c]:
228
+ self.solution[r][c] = 2
229
+ else:
230
+ self.solution[r][c] = 0
231
+
232
+ return placed_pairs >= num_pairs
233
+
234
+ async def validate_move(self, row: int, col: int, action: str = "place") -> MoveResult:
235
+ """Place or remove a tent.
236
+
237
+ Args:
238
+ row: Row index (1-indexed, user-facing)
239
+ col: Column index (1-indexed, user-facing)
240
+ action: "place" or "remove" (default: "place")
241
+
242
+ Returns:
243
+ MoveResult with success status and message
244
+ """
245
+ # Convert to 0-indexed
246
+ row -= 1
247
+ col -= 1
248
+
249
+ # Validate coordinates
250
+ if not (0 <= row < self.size and 0 <= col < self.size):
251
+ return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
252
+
253
+ # Check if it's a tree
254
+ if self.trees[row][col]:
255
+ return MoveResult(success=False, message="Cannot place tent on a tree.")
256
+
257
+ action = action.lower()
258
+
259
+ if action == "remove":
260
+ if self.grid[row][col] != 2:
261
+ return MoveResult(success=False, message="No tent to remove at this position.")
262
+ self.grid[row][col] = 0
263
+ self.moves_made += 1
264
+ return MoveResult(success=True, message="Tent removed.", state_changed=True)
265
+
266
+ elif action == "place":
267
+ if self.grid[row][col] == 2:
268
+ return MoveResult(success=False, message="Tent already placed here.")
269
+
270
+ # Check if tent would touch another tent (including diagonally)
271
+ all_adj = self._get_all_adjacent(row, col)
272
+ if any(self.grid[ar][ac] == 2 for ar, ac in all_adj):
273
+ return MoveResult(success=False, message="Tents cannot touch each other (not even diagonally).")
274
+
275
+ self.grid[row][col] = 2
276
+ self.moves_made += 1
277
+ return MoveResult(success=True, message="Tent placed!", state_changed=True)
278
+
279
+ else:
280
+ return MoveResult(success=False, message="Invalid action. Use 'place' or 'remove'.")
281
+
282
+ def is_complete(self) -> bool:
283
+ """Check if the puzzle is complete and correct."""
284
+ # Check row and column counts
285
+ for r in range(self.size):
286
+ count = sum(1 for c in range(self.size) if self.grid[r][c] == 2)
287
+ if count != self.row_counts[r]:
288
+ return False
289
+
290
+ for c in range(self.size):
291
+ count = sum(1 for r in range(self.size) if self.grid[r][c] == 2)
292
+ if count != self.col_counts[c]:
293
+ return False
294
+
295
+ # Check each tree has exactly one adjacent tent
296
+ for r in range(self.size):
297
+ for c in range(self.size):
298
+ if self.trees[r][c]:
299
+ adjacent = self._get_adjacent(r, c)
300
+ tent_count = sum(1 for ar, ac in adjacent if self.grid[ar][ac] == 2)
301
+ if tent_count != 1:
302
+ return False
303
+
304
+ # Check each tent has exactly one adjacent tree
305
+ for r in range(self.size):
306
+ for c in range(self.size):
307
+ if self.grid[r][c] == 2:
308
+ adjacent = self._get_adjacent(r, c)
309
+ tree_count = sum(1 for ar, ac in adjacent if self.trees[ar][ac])
310
+ if tree_count != 1:
311
+ return False
312
+
313
+ # Check no tents touch
314
+ for r in range(self.size):
315
+ for c in range(self.size):
316
+ if self.grid[r][c] == 2:
317
+ all_adj = self._get_all_adjacent(r, c)
318
+ if any(self.grid[ar][ac] == 2 for ar, ac in all_adj):
319
+ return False
320
+
321
+ return True
322
+
323
+ async def get_hint(self) -> tuple[Any, str] | None:
324
+ """Get a hint for the next move.
325
+
326
+ Returns:
327
+ Tuple of (hint_data, hint_message) or None if puzzle is complete
328
+ """
329
+ # Find a tent location from solution that hasn't been placed
330
+ for r in range(self.size):
331
+ for c in range(self.size):
332
+ if self.solution[r][c] == 2 and self.grid[r][c] != 2:
333
+ hint_data = (r + 1, c + 1, "place")
334
+ hint_message = f"Try placing a tent at row {r + 1}, column {c + 1}"
335
+ return hint_data, hint_message
336
+
337
+ # Find incorrectly placed tent
338
+ for r in range(self.size):
339
+ for c in range(self.size):
340
+ if self.grid[r][c] == 2 and self.solution[r][c] != 2:
341
+ hint_data = (r + 1, c + 1, "remove")
342
+ hint_message = f"Remove the tent at row {r + 1}, column {c + 1}"
343
+ return hint_data, hint_message
344
+
345
+ return None
346
+
347
+ def render_grid(self) -> str:
348
+ """Render the current puzzle state as ASCII art.
349
+
350
+ Returns:
351
+ String representation of the puzzle grid
352
+ """
353
+ lines = []
354
+
355
+ # Header with column counts
356
+ header = " |"
357
+ for c in range(self.size):
358
+ header += f" {self.col_counts[c]}"
359
+ lines.append(header)
360
+ lines.append(" +" + "--" * self.size)
361
+
362
+ # Grid rows with row counts
363
+ for r in range(self.size):
364
+ row_str = f" {self.row_counts[r]} |"
365
+ for c in range(self.size):
366
+ if self.trees[r][c]:
367
+ row_str += " T"
368
+ elif self.grid[r][c] == 2:
369
+ row_str += " A" # A for tent (like a triangle)
370
+ else:
371
+ row_str += " ."
372
+ lines.append(row_str)
373
+
374
+ lines.append("\nLegend: T = tree, A = tent (player placed), . = empty")
375
+ lines.append("Numbers on edges = required tents in that row/column")
376
+
377
+ return "\n".join(lines)
378
+
379
+ def get_rules(self) -> str:
380
+ """Get the rules description for Tents and Trees.
381
+
382
+ Returns:
383
+ Multi-line string describing the puzzle rules
384
+ """
385
+ return """TENTS AND TREES RULES:
386
+ - Place tents in empty cells
387
+ - Each tree must have exactly one tent adjacent to it (horizontally or vertically)
388
+ - Each tent must be adjacent to exactly one tree
389
+ - Tents cannot touch each other, not even diagonally
390
+ - Row and column numbers show how many tents are in that row/column"""
391
+
392
+ def get_commands(self) -> str:
393
+ """Get the available commands for Tents and Trees.
394
+
395
+ Returns:
396
+ Multi-line string describing available commands
397
+ """
398
+ return """TENTS AND TREES COMMANDS:
399
+ place <row> <col> - Place a tent (e.g., 'place 2 3')
400
+ remove <row> <col> - Remove a tent (e.g., 'remove 2 3')
401
+ show - Display the current grid
402
+ hint - Get a hint for the next move
403
+ check - Check your progress
404
+ solve - Show the solution (ends game)
405
+ menu - Return to game selection
406
+ quit - Exit the server"""
407
+
408
+ def get_stats(self) -> str:
409
+ """Get current game statistics.
410
+
411
+ Returns:
412
+ String with game stats
413
+ """
414
+ placed = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 2)
415
+ required = sum(self.row_counts)
416
+ return f"Moves made: {self.moves_made} | Tents placed: {placed}/{required} | Seed: {self.seed}"