chuk-puzzles-gym 0.9__py3-none-any.whl → 0.10.1__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 (55) hide show
  1. chuk_puzzles_gym/eval.py +21 -0
  2. chuk_puzzles_gym/games/__init__.py +22 -0
  3. chuk_puzzles_gym/games/binary/game.py +2 -0
  4. chuk_puzzles_gym/games/bridges/game.py +2 -0
  5. chuk_puzzles_gym/games/cryptarithmetic/__init__.py +7 -0
  6. chuk_puzzles_gym/games/cryptarithmetic/commands.py +75 -0
  7. chuk_puzzles_gym/games/cryptarithmetic/config.py +23 -0
  8. chuk_puzzles_gym/games/cryptarithmetic/game.py +388 -0
  9. chuk_puzzles_gym/games/einstein/game.py +2 -0
  10. chuk_puzzles_gym/games/fillomino/game.py +2 -0
  11. chuk_puzzles_gym/games/futoshiki/game.py +2 -0
  12. chuk_puzzles_gym/games/graph_coloring/__init__.py +7 -0
  13. chuk_puzzles_gym/games/graph_coloring/commands.py +96 -0
  14. chuk_puzzles_gym/games/graph_coloring/config.py +24 -0
  15. chuk_puzzles_gym/games/graph_coloring/game.py +316 -0
  16. chuk_puzzles_gym/games/hidato/game.py +2 -0
  17. chuk_puzzles_gym/games/hitori/game.py +2 -0
  18. chuk_puzzles_gym/games/kakuro/game.py +2 -0
  19. chuk_puzzles_gym/games/kenken/game.py +2 -0
  20. chuk_puzzles_gym/games/killer_sudoku/game.py +2 -0
  21. chuk_puzzles_gym/games/knapsack/game.py +2 -0
  22. chuk_puzzles_gym/games/lights_out/game.py +2 -0
  23. chuk_puzzles_gym/games/logic_grid/game.py +2 -0
  24. chuk_puzzles_gym/games/mastermind/game.py +2 -0
  25. chuk_puzzles_gym/games/minesweeper/game.py +2 -0
  26. chuk_puzzles_gym/games/nonogram/game.py +2 -0
  27. chuk_puzzles_gym/games/nqueens/__init__.py +6 -0
  28. chuk_puzzles_gym/games/nqueens/config.py +23 -0
  29. chuk_puzzles_gym/games/nqueens/game.py +321 -0
  30. chuk_puzzles_gym/games/numberlink/__init__.py +6 -0
  31. chuk_puzzles_gym/games/numberlink/config.py +23 -0
  32. chuk_puzzles_gym/games/numberlink/game.py +344 -0
  33. chuk_puzzles_gym/games/nurikabe/game.py +2 -0
  34. chuk_puzzles_gym/games/rush_hour/__init__.py +8 -0
  35. chuk_puzzles_gym/games/rush_hour/commands.py +57 -0
  36. chuk_puzzles_gym/games/rush_hour/config.py +25 -0
  37. chuk_puzzles_gym/games/rush_hour/game.py +479 -0
  38. chuk_puzzles_gym/games/rush_hour/models.py +15 -0
  39. chuk_puzzles_gym/games/scheduler/game.py +2 -0
  40. chuk_puzzles_gym/games/shikaku/game.py +2 -0
  41. chuk_puzzles_gym/games/skyscrapers/__init__.py +6 -0
  42. chuk_puzzles_gym/games/skyscrapers/config.py +22 -0
  43. chuk_puzzles_gym/games/skyscrapers/game.py +282 -0
  44. chuk_puzzles_gym/games/slitherlink/game.py +2 -0
  45. chuk_puzzles_gym/games/sokoban/game.py +2 -0
  46. chuk_puzzles_gym/games/star_battle/game.py +2 -0
  47. chuk_puzzles_gym/games/sudoku/game.py +2 -0
  48. chuk_puzzles_gym/games/tents/game.py +2 -0
  49. chuk_puzzles_gym/server.py +18 -70
  50. chuk_puzzles_gym/trace/generator.py +87 -0
  51. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/METADATA +60 -19
  52. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/RECORD +55 -33
  53. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/WHEEL +1 -1
  54. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/entry_points.txt +0 -0
  55. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,282 @@
1
+ """Skyscrapers puzzle game implementation."""
2
+
3
+ from typing import Any
4
+
5
+ from ...models import DifficultyLevel, DifficultyProfile, MoveResult
6
+ from .._base import PuzzleGame
7
+ from .config import SkyscrapersConfig
8
+
9
+
10
+ class SkyscrapersGame(PuzzleGame):
11
+ """Skyscrapers puzzle - fill a Latin square with visibility clues.
12
+
13
+ Rules:
14
+ - Fill an NxN grid with numbers 1 to N
15
+ - Each row and column must contain each number exactly once (Latin square)
16
+ - Numbers represent building heights
17
+ - Clues around the border indicate how many buildings are visible from that direction
18
+ - A taller building hides all shorter buildings behind it
19
+ """
20
+
21
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
22
+ super().__init__(difficulty, seed, **kwargs)
23
+ self.config = SkyscrapersConfig.from_difficulty(self.difficulty)
24
+ self.size = self.config.size
25
+ self.grid: list[list[int]] = []
26
+ self.solution: list[list[int]] = []
27
+ self.initial_grid: list[list[int]] = []
28
+ self.clues: dict[str, list[int]] = {"top": [], "bottom": [], "left": [], "right": []}
29
+
30
+ @property
31
+ def name(self) -> str:
32
+ return "Skyscrapers"
33
+
34
+ @property
35
+ def description(self) -> str:
36
+ return "Fill the grid with building heights using visibility clues"
37
+
38
+ @property
39
+ def constraint_types(self) -> list[str]:
40
+ return ["all_different", "visibility", "ordering", "boundary_clues"]
41
+
42
+ @property
43
+ def business_analogies(self) -> list[str]:
44
+ return ["urban_planning", "line_of_sight_analysis", "signal_visibility"]
45
+
46
+ @property
47
+ def complexity_profile(self) -> dict[str, str]:
48
+ return {
49
+ "reasoning_type": "deductive",
50
+ "search_space": "medium",
51
+ "constraint_density": "dense",
52
+ }
53
+
54
+ @property
55
+ def complexity_metrics(self) -> dict[str, int | float]:
56
+ empty = sum(1 for row in self.grid for cell in row if cell == 0)
57
+ return {
58
+ "variable_count": self.size * self.size,
59
+ "constraint_count": 2 * self.size + 4 * self.size,
60
+ "domain_size": self.size,
61
+ "branching_factor": self.size / 2.0,
62
+ "empty_cells": empty,
63
+ }
64
+
65
+ @property
66
+ def difficulty_profile(self) -> DifficultyProfile:
67
+ profiles = {
68
+ DifficultyLevel.EASY: DifficultyProfile(
69
+ logic_depth=2, branching_factor=2.0, state_observability=1.0, constraint_density=0.7
70
+ ),
71
+ DifficultyLevel.MEDIUM: DifficultyProfile(
72
+ logic_depth=4, branching_factor=3.0, state_observability=1.0, constraint_density=0.6
73
+ ),
74
+ DifficultyLevel.HARD: DifficultyProfile(
75
+ logic_depth=6, branching_factor=4.0, state_observability=1.0, constraint_density=0.5
76
+ ),
77
+ }
78
+ return profiles[self.difficulty]
79
+
80
+ def _compute_visibility(self, line: list[int]) -> int:
81
+ """Count how many buildings are visible from the start of a line."""
82
+ count = 0
83
+ max_height = 0
84
+ for h in line:
85
+ if h > max_height:
86
+ count += 1
87
+ max_height = h
88
+ return count
89
+
90
+ def _generate_latin_square(self) -> list[list[int]]:
91
+ """Generate a random NxN Latin square."""
92
+ n = self.size
93
+ # Start with a shifted-row construction
94
+ base = list(range(1, n + 1))
95
+ grid = []
96
+ for r in range(n):
97
+ row = [(base[(r + c) % n]) for c in range(n)]
98
+ grid.append(row)
99
+
100
+ # Shuffle rows
101
+ rows = list(range(n))
102
+ self._rng.shuffle(rows)
103
+ grid = [grid[r] for r in rows]
104
+
105
+ # Shuffle columns
106
+ cols = list(range(n))
107
+ self._rng.shuffle(cols)
108
+ grid = [[row[c] for c in cols] for row in grid]
109
+
110
+ # Shuffle values (relabel)
111
+ perm = list(range(1, n + 1))
112
+ self._rng.shuffle(perm)
113
+ mapping = {i + 1: perm[i] for i in range(n)}
114
+ grid = [[mapping[cell] for cell in row] for row in grid]
115
+
116
+ return grid
117
+
118
+ def _compute_all_clues(self, grid: list[list[int]]) -> dict[str, list[int]]:
119
+ """Compute visibility clues from all 4 directions."""
120
+ n = self.size
121
+ clues: dict[str, list[int]] = {"top": [], "bottom": [], "left": [], "right": []}
122
+
123
+ for c in range(n):
124
+ col = [grid[r][c] for r in range(n)]
125
+ clues["top"].append(self._compute_visibility(col))
126
+ clues["bottom"].append(self._compute_visibility(col[::-1]))
127
+
128
+ for r in range(n):
129
+ clues["left"].append(self._compute_visibility(grid[r]))
130
+ clues["right"].append(self._compute_visibility(grid[r][::-1]))
131
+
132
+ return clues
133
+
134
+ async def generate_puzzle(self) -> None:
135
+ """Generate a Skyscrapers puzzle."""
136
+ self.solution = self._generate_latin_square()
137
+ self.clues = self._compute_all_clues(self.solution)
138
+
139
+ # Copy solution to grid, then remove cells based on difficulty
140
+ self.grid = [row[:] for row in self.solution]
141
+
142
+ # Determine cells to remove
143
+ n = self.size
144
+ total_cells = n * n
145
+ remove_map = {
146
+ DifficultyLevel.EASY: int(total_cells * 0.45),
147
+ DifficultyLevel.MEDIUM: int(total_cells * 0.60),
148
+ DifficultyLevel.HARD: int(total_cells * 0.75),
149
+ }
150
+ cells_to_remove = remove_map[self.difficulty]
151
+
152
+ # Randomly remove cells
153
+ all_cells = [(r, c) for r in range(n) for c in range(n)]
154
+ self._rng.shuffle(all_cells)
155
+ for r, c in all_cells[:cells_to_remove]:
156
+ self.grid[r][c] = 0
157
+
158
+ self.initial_grid = [row[:] for row in self.grid]
159
+ self.game_started = True
160
+
161
+ async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
162
+ """Validate placing a height value.
163
+
164
+ Args:
165
+ row: 1-indexed row
166
+ col: 1-indexed column
167
+ num: Height value (1-N) or 0 to clear
168
+ """
169
+ n = self.size
170
+ r, c = row - 1, col - 1
171
+
172
+ if not (0 <= r < n and 0 <= c < n):
173
+ self.record_move((row, col), False)
174
+ return MoveResult(success=False, message=f"Position ({row}, {col}) is out of bounds.")
175
+
176
+ if self.initial_grid[r][c] != 0:
177
+ self.record_move((row, col), False)
178
+ return MoveResult(success=False, message="Cannot modify an initial cell.")
179
+
180
+ if num == 0:
181
+ self.grid[r][c] = 0
182
+ self.record_move((row, col), True)
183
+ return MoveResult(success=True, message=f"Cleared cell ({row}, {col}).", state_changed=True)
184
+
185
+ if not (1 <= num <= n):
186
+ self.record_move((row, col), False)
187
+ return MoveResult(success=False, message=f"Value must be between 1 and {n}.")
188
+
189
+ # Check row uniqueness
190
+ for cc in range(n):
191
+ if cc != c and self.grid[r][cc] == num:
192
+ self.record_move((row, col), False)
193
+ return MoveResult(
194
+ success=False,
195
+ message=f"Value {num} already exists in row {row}.",
196
+ )
197
+
198
+ # Check column uniqueness
199
+ for rr in range(n):
200
+ if rr != r and self.grid[rr][c] == num:
201
+ self.record_move((row, col), False)
202
+ return MoveResult(
203
+ success=False,
204
+ message=f"Value {num} already exists in column {col}.",
205
+ )
206
+
207
+ self.grid[r][c] = num
208
+ self.record_move((row, col), True)
209
+ return MoveResult(
210
+ success=True,
211
+ message=f"Placed {num} at ({row}, {col}).",
212
+ state_changed=True,
213
+ )
214
+
215
+ def is_complete(self) -> bool:
216
+ """Check if the puzzle is solved correctly."""
217
+ return self.grid == self.solution
218
+
219
+ async def get_hint(self) -> tuple[Any, str] | None:
220
+ """Get a hint - suggest a cell to fill."""
221
+ if not self.can_use_hint():
222
+ return None
223
+ n = self.size
224
+ for r in range(n):
225
+ for c in range(n):
226
+ if self.grid[r][c] == 0:
227
+ val = self.solution[r][c]
228
+ return (
229
+ (r + 1, c + 1, val),
230
+ f"Try placing {val} at row {r + 1}, column {c + 1}.",
231
+ )
232
+ return None
233
+
234
+ def render_grid(self) -> str:
235
+ """Render the puzzle with visibility clues."""
236
+ n = self.size
237
+ lines = []
238
+
239
+ # Top clues
240
+ top_clues = " " + " ".join(str(c) if c > 0 else " " for c in self.clues["top"])
241
+ lines.append(top_clues)
242
+ lines.append(" " + "+" + "---" * n + "+")
243
+
244
+ # Grid rows with left/right clues
245
+ for r in range(n):
246
+ left = str(self.clues["left"][r]) if self.clues["left"][r] > 0 else " "
247
+ right = str(self.clues["right"][r]) if self.clues["right"][r] > 0 else " "
248
+ cells = " ".join(str(v) if v != 0 else "." for v in self.grid[r])
249
+ lines.append(f" {left} | {cells} | {right}")
250
+
251
+ # Bottom border and clues
252
+ lines.append(" " + "+" + "---" * n + "+")
253
+ bot_clues = " " + " ".join(str(c) if c > 0 else " " for c in self.clues["bottom"])
254
+ lines.append(bot_clues)
255
+
256
+ return "\n".join(lines)
257
+
258
+ def get_stats(self) -> str:
259
+ """Get current game statistics."""
260
+ empty = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
261
+ return f"Moves: {self.moves_made} | Empty cells: {empty} | Grid: {self.size}x{self.size} | Seed: {self.seed}"
262
+
263
+ def get_rules(self) -> str:
264
+ return (
265
+ f"SKYSCRAPERS ({self.size}x{self.size})\n"
266
+ f"Fill the grid with numbers 1 to {self.size}.\n"
267
+ "Each row and column must contain each number exactly once.\n"
268
+ "Numbers represent building heights.\n"
269
+ "Clues around the border show how many buildings are visible from that direction.\n"
270
+ "A taller building hides all shorter ones behind it."
271
+ )
272
+
273
+ def get_commands(self) -> str:
274
+ return (
275
+ "Commands:\n"
276
+ f" place <row> <col> <height> - Place a height (1-{self.size})\n"
277
+ " clear <row> <col> - Clear a cell\n"
278
+ " hint - Get a hint\n"
279
+ " check - Check if solved\n"
280
+ " show - Show current state\n"
281
+ " menu - Return to menu"
282
+ )
@@ -272,6 +272,8 @@ class SlitherlinkGame(PuzzleGame):
272
272
  Returns:
273
273
  Tuple of (hint_data, hint_message) or None
274
274
  """
275
+ if not self.can_use_hint():
276
+ return None
275
277
  # Find an edge that's in the solution but not set by player
276
278
  for row in range(self.size + 1):
277
279
  for col in range(self.size):
@@ -499,6 +499,8 @@ class SokobanGame(PuzzleGame):
499
499
  Returns:
500
500
  Tuple of (hint_data, hint_message) or None
501
501
  """
502
+ if not self.can_use_hint():
503
+ return None
502
504
  if self.is_complete():
503
505
  return None
504
506
 
@@ -301,6 +301,8 @@ class StarBattleGame(PuzzleGame):
301
301
  Returns:
302
302
  Tuple of (hint_data, hint_message) or None if puzzle is complete
303
303
  """
304
+ if not self.can_use_hint():
305
+ return None
304
306
  # Find a star location from solution that hasn't been placed
305
307
  for r in range(self.size):
306
308
  for c in range(self.size):
@@ -249,6 +249,8 @@ class SudokuGame(PuzzleGame):
249
249
  Returns:
250
250
  Tuple of (hint_data, hint_message) or None if puzzle is complete
251
251
  """
252
+ if not self.can_use_hint():
253
+ return None
252
254
  empty_cells = [(r, c) for r in range(9) for c in range(9) if self.grid[r][c] == 0]
253
255
  if not empty_cells:
254
256
  return None
@@ -326,6 +326,8 @@ class TentsGame(PuzzleGame):
326
326
  Returns:
327
327
  Tuple of (hint_data, hint_message) or None if puzzle is complete
328
328
  """
329
+ if not self.can_use_hint():
330
+ return None
329
331
  # Find a tent location from solution that hasn't been placed
330
332
  for r in range(self.size):
331
333
  for c in range(self.size):
@@ -202,7 +202,7 @@ class ArcadeHandler(TelnetHandler):
202
202
  # Set up command handler if available for this game
203
203
  handler_class = GAME_COMMAND_HANDLERS.get(game_id.lower())
204
204
  if handler_class:
205
- self.game_handler = handler_class(self.current_game)
205
+ self.game_handler = handler_class(self.current_game) # type: ignore[abstract]
206
206
  else:
207
207
  self.game_handler = None
208
208
 
@@ -599,10 +599,6 @@ class ArcadeHandler(TelnetHandler):
599
599
  if self.game_handler and cmd_enum in self.game_handler.supported_commands:
600
600
  result = await self.game_handler.handle_command(cmd_enum, parts[1:])
601
601
 
602
- # Track invalid moves
603
- if not result.result.success:
604
- self.current_game.invalid_moves += 1
605
-
606
602
  # Send result based on output mode
607
603
  code = "OK" if result.result.success else "INVALID"
608
604
  await self.send_result(result.result.success, result.result.message, code)
@@ -626,9 +622,7 @@ class ArcadeHandler(TelnetHandler):
626
622
  num = int(parts[3])
627
623
 
628
624
  result = await self.current_game.validate_move(row, col, num)
629
-
630
- if not result.success:
631
- self.current_game.invalid_moves += 1
625
+ self.current_game.record_move((row, col), result.success)
632
626
 
633
627
  await self.send_result(result.success, result.message, "PLACED" if result.success else "INVALID_MOVE")
634
628
 
@@ -639,7 +633,6 @@ class ArcadeHandler(TelnetHandler):
639
633
  await self.send_game_complete()
640
634
 
641
635
  except ValueError:
642
- self.current_game.invalid_moves += 1
643
636
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
644
637
  return
645
638
 
@@ -653,9 +646,7 @@ class ArcadeHandler(TelnetHandler):
653
646
  col = int(parts[2])
654
647
 
655
648
  result = await self.current_game.validate_move(row, col, 0)
656
-
657
- if not result.success:
658
- self.current_game.invalid_moves += 1
649
+ self.current_game.record_move((row, col), result.success)
659
650
 
660
651
  await self.send_result(result.success, result.message, "CLEARED" if result.success else "INVALID_CLEAR")
661
652
 
@@ -663,7 +654,6 @@ class ArcadeHandler(TelnetHandler):
663
654
  await self.display_puzzle()
664
655
 
665
656
  except ValueError:
666
- self.current_game.invalid_moves += 1
667
657
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
668
658
  return
669
659
 
@@ -693,9 +683,7 @@ class ArcadeHandler(TelnetHandler):
693
683
  col = int(parts[2])
694
684
 
695
685
  result = await self.current_game.validate_move(row, col)
696
-
697
- if not result.success:
698
- self.current_game.invalid_moves += 1
686
+ self.current_game.record_move((row, col), result.success)
699
687
 
700
688
  await self.send_result(result.success, result.message, "PRESSED" if result.success else "INVALID_PRESS")
701
689
 
@@ -706,7 +694,6 @@ class ArcadeHandler(TelnetHandler):
706
694
  await self.send_game_complete()
707
695
 
708
696
  except ValueError:
709
- self.current_game.invalid_moves += 1
710
697
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
711
698
  return
712
699
 
@@ -718,9 +705,7 @@ class ArcadeHandler(TelnetHandler):
718
705
 
719
706
  cat1, val1, cat2, val2 = parts[1], parts[2], parts[3], parts[4]
720
707
  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
708
+ self.current_game.record_move((cat1, val1, cat2, val2), result.success)
724
709
 
725
710
  await self.send_result(result.success, result.message, "CONNECTED" if result.success else "INVALID_CONNECT")
726
711
  if result.success:
@@ -736,9 +721,7 @@ class ArcadeHandler(TelnetHandler):
736
721
 
737
722
  cat1, val1, cat2, val2 = parts[1], parts[2], parts[3], parts[4]
738
723
  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
724
+ self.current_game.record_move((cat1, val1, cat2, val2), result.success)
742
725
 
743
726
  await self.send_result(result.success, result.message, "EXCLUDED" if result.success else "INVALID_EXCLUDE")
744
727
  if result.success:
@@ -758,9 +741,7 @@ class ArcadeHandler(TelnetHandler):
758
741
  col = int(parts[2])
759
742
 
760
743
  result = await self.current_game.validate_move("reveal", row, col)
761
-
762
- if not result.success:
763
- self.current_game.invalid_moves += 1
744
+ self.current_game.record_move((row, col), result.success)
764
745
 
765
746
  await self.send_result(
766
747
  result.success, result.message, "REVEALED" if result.success else "INVALID_REVEAL"
@@ -783,7 +764,6 @@ class ArcadeHandler(TelnetHandler):
783
764
  await self.send_line("=" * 50 + "\n")
784
765
 
785
766
  except ValueError:
786
- self.current_game.invalid_moves += 1
787
767
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
788
768
  return
789
769
 
@@ -797,9 +777,7 @@ class ArcadeHandler(TelnetHandler):
797
777
  col = int(parts[2])
798
778
 
799
779
  result = await self.current_game.validate_move("flag", row, col)
800
-
801
- if not result.success:
802
- self.current_game.invalid_moves += 1
780
+ self.current_game.record_move((row, col), result.success)
803
781
 
804
782
  await self.send_result(result.success, result.message, "FLAGGED" if result.success else "INVALID_FLAG")
805
783
 
@@ -807,7 +785,6 @@ class ArcadeHandler(TelnetHandler):
807
785
  await self.display_puzzle()
808
786
 
809
787
  except ValueError:
810
- self.current_game.invalid_moves += 1
811
788
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
812
789
  return
813
790
 
@@ -824,9 +801,7 @@ class ArcadeHandler(TelnetHandler):
824
801
  state = int(parts[4])
825
802
 
826
803
  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
804
+ self.current_game.record_move((edge_type, row, col), result.success)
830
805
 
831
806
  await self.send_result(result.success, result.message, "SET" if result.success else "INVALID_SET")
832
807
 
@@ -837,7 +812,6 @@ class ArcadeHandler(TelnetHandler):
837
812
  await self.send_game_complete()
838
813
 
839
814
  except ValueError:
840
- self.current_game.invalid_moves += 1
841
815
  await self.send_result(False, "Invalid input. Use numbers only for row, col, state.", "PARSE_ERROR")
842
816
  return
843
817
 
@@ -851,9 +825,7 @@ class ArcadeHandler(TelnetHandler):
851
825
  guess = [int(p) for p in parts[1:]]
852
826
 
853
827
  result = await self.current_game.validate_move(*guess)
854
-
855
- if not result.success:
856
- self.current_game.invalid_moves += 1
828
+ self.current_game.record_move(tuple(guess), result.success)
857
829
 
858
830
  await self.send_result(result.success, result.message, "GUESSED" if result.success else "INVALID_GUESS")
859
831
 
@@ -874,7 +846,6 @@ class ArcadeHandler(TelnetHandler):
874
846
  await self.send_line("=" * 50 + "\n")
875
847
 
876
848
  except ValueError:
877
- self.current_game.invalid_moves += 1
878
849
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
879
850
  return
880
851
 
@@ -888,9 +859,7 @@ class ArcadeHandler(TelnetHandler):
888
859
  item_index = int(parts[1])
889
860
 
890
861
  result = await self.current_game.validate_move("select", item_index)
891
-
892
- if not result.success:
893
- self.current_game.invalid_moves += 1
862
+ self.current_game.record_move((item_index,), result.success)
894
863
 
895
864
  await self.send_result(
896
865
  result.success, result.message, "SELECTED" if result.success else "INVALID_SELECT"
@@ -900,7 +869,6 @@ class ArcadeHandler(TelnetHandler):
900
869
  await self.display_puzzle()
901
870
 
902
871
  except ValueError:
903
- self.current_game.invalid_moves += 1
904
872
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
905
873
  return
906
874
 
@@ -913,9 +881,7 @@ class ArcadeHandler(TelnetHandler):
913
881
  item_index = int(parts[1])
914
882
 
915
883
  result = await self.current_game.validate_move("deselect", item_index)
916
-
917
- if not result.success:
918
- self.current_game.invalid_moves += 1
884
+ self.current_game.record_move((item_index,), result.success)
919
885
 
920
886
  await self.send_result(
921
887
  result.success, result.message, "DESELECTED" if result.success else "INVALID_DESELECT"
@@ -925,7 +891,6 @@ class ArcadeHandler(TelnetHandler):
925
891
  await self.display_puzzle()
926
892
 
927
893
  except ValueError:
928
- self.current_game.invalid_moves += 1
929
894
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
930
895
  return
931
896
 
@@ -941,9 +906,7 @@ class ArcadeHandler(TelnetHandler):
941
906
  color = parts[3].lower()
942
907
 
943
908
  result = await self.current_game.validate_move(row, col, color)
944
-
945
- if not result.success:
946
- self.current_game.invalid_moves += 1
909
+ self.current_game.record_move((row, col), result.success)
947
910
 
948
911
  await self.send_result(result.success, result.message, "MARKED" if result.success else "INVALID_MARK")
949
912
 
@@ -954,7 +917,6 @@ class ArcadeHandler(TelnetHandler):
954
917
  await self.send_game_complete()
955
918
 
956
919
  except ValueError:
957
- self.current_game.invalid_moves += 1
958
920
  await self.send_result(False, "Invalid input. Row and col must be numbers.", "PARSE_ERROR")
959
921
  return
960
922
 
@@ -969,9 +931,7 @@ class ArcadeHandler(TelnetHandler):
969
931
  col = int(parts[2])
970
932
 
971
933
  result = await self.current_game.validate_move(row, col, "shade")
972
-
973
- if not result.success:
974
- self.current_game.invalid_moves += 1
934
+ self.current_game.record_move((row, col), result.success)
975
935
 
976
936
  await self.send_result(result.success, result.message, "SHADED" if result.success else "INVALID_SHADE")
977
937
 
@@ -982,7 +942,6 @@ class ArcadeHandler(TelnetHandler):
982
942
  await self.send_game_complete()
983
943
 
984
944
  except ValueError:
985
- self.current_game.invalid_moves += 1
986
945
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
987
946
  return
988
947
 
@@ -1000,9 +959,7 @@ class ArcadeHandler(TelnetHandler):
1000
959
  count = int(parts[5])
1001
960
 
1002
961
  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
962
+ self.current_game.record_move((r1, c1, r2, c2), result.success)
1006
963
 
1007
964
  await self.send_result(
1008
965
  result.success, result.message, "BRIDGED" if result.success else "INVALID_BRIDGE"
@@ -1015,7 +972,6 @@ class ArcadeHandler(TelnetHandler):
1015
972
  await self.send_game_complete()
1016
973
 
1017
974
  except ValueError:
1018
- self.current_game.invalid_moves += 1
1019
975
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
1020
976
  return
1021
977
 
@@ -1028,9 +984,7 @@ class ArcadeHandler(TelnetHandler):
1028
984
  direction = parts[1].lower()
1029
985
 
1030
986
  result = await self.current_game.validate_move(direction)
1031
-
1032
- if not result.success:
1033
- self.current_game.invalid_moves += 1
987
+ self.current_game.record_move((direction,), result.success)
1034
988
 
1035
989
  await self.send_result(result.success, result.message, "MOVED" if result.success else "INVALID_MOVE")
1036
990
 
@@ -1054,9 +1008,7 @@ class ArcadeHandler(TelnetHandler):
1054
1008
  start_time = int(parts[3])
1055
1009
 
1056
1010
  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
1011
+ self.current_game.record_move((task_id,), result.success)
1060
1012
 
1061
1013
  await self.send_result(
1062
1014
  result.success, result.message, "ASSIGNED" if result.success else "INVALID_ASSIGN"
@@ -1068,7 +1020,6 @@ class ArcadeHandler(TelnetHandler):
1068
1020
  await self.send_game_complete()
1069
1021
 
1070
1022
  except ValueError:
1071
- self.current_game.invalid_moves += 1
1072
1023
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
1073
1024
  return
1074
1025
 
@@ -1081,9 +1032,7 @@ class ArcadeHandler(TelnetHandler):
1081
1032
  task_id = int(parts[1])
1082
1033
 
1083
1034
  result = await self.current_game.validate_move(task_id, 0, -1)
1084
-
1085
- if not result.success:
1086
- self.current_game.invalid_moves += 1
1035
+ self.current_game.record_move((task_id,), result.success)
1087
1036
 
1088
1037
  await self.send_result(
1089
1038
  result.success, result.message, "UNASSIGNED" if result.success else "INVALID_UNASSIGN"
@@ -1093,7 +1042,6 @@ class ArcadeHandler(TelnetHandler):
1093
1042
  await self.display_puzzle()
1094
1043
 
1095
1044
  except ValueError:
1096
- self.current_game.invalid_moves += 1
1097
1045
  await self.send_result(False, "Invalid input. Use numbers only.", "PARSE_ERROR")
1098
1046
  return
1099
1047