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
chuk_puzzles_gym/eval.py CHANGED
@@ -235,6 +235,9 @@ async def _apply_hint(game: PuzzleGame, hint_data: tuple) -> MoveResult:
235
235
  "nonogram",
236
236
  "hidato",
237
237
  "fillomino",
238
+ "skyscrapers",
239
+ "n-queens",
240
+ "numberlink",
238
241
  ]:
239
242
  if len(hint_data) >= 3:
240
243
  row, col, value = hint_data[0], hint_data[1], hint_data[2]
@@ -342,6 +345,24 @@ async def _apply_hint(game: PuzzleGame, hint_data: tuple) -> MoveResult:
342
345
  direction = hint_data if isinstance(hint_data, str) else hint_data
343
346
  return await game.validate_move(direction)
344
347
 
348
+ # Graph Coloring - hint is (node, color)
349
+ if game_name in ["graph coloring"]:
350
+ if len(hint_data) >= 2:
351
+ node, color = hint_data[0], hint_data[1]
352
+ return await game.validate_move(node, color)
353
+
354
+ # Cryptarithmetic - hint is (letter, digit)
355
+ if game_name in ["cryptarithmetic"]:
356
+ if len(hint_data) >= 2:
357
+ letter, digit = hint_data[0], hint_data[1]
358
+ return await game.validate_move(letter, digit)
359
+
360
+ # Rush Hour - hint is (vehicle_id, direction)
361
+ if game_name in ["rush hour"]:
362
+ if len(hint_data) >= 2:
363
+ vehicle_id, direction = hint_data[0], hint_data[1]
364
+ return await game.validate_move(vehicle_id, direction)
365
+
345
366
  # Generic fallback - try validate_move with hint args as tuple
346
367
  if isinstance(hint_data, tuple) and len(hint_data) >= 2:
347
368
  return await game.validate_move(*hint_data)
@@ -2,9 +2,11 @@
2
2
 
3
3
  from .binary import BinaryPuzzleGame
4
4
  from .bridges import BridgesGame
5
+ from .cryptarithmetic import CryptarithmeticCommandHandler, CryptarithmeticGame
5
6
  from .einstein import EinsteinGame
6
7
  from .fillomino import FillominoGame
7
8
  from .futoshiki import FutoshikiGame
9
+ from .graph_coloring import GraphColoringCommandHandler, GraphColoringGame
8
10
  from .hidato import HidatoGame
9
11
  from .hitori import HitoriGame
10
12
  from .kakuro import KakuroGame
@@ -16,9 +18,13 @@ from .logic_grid import LogicGridGame
16
18
  from .mastermind import MastermindGame
17
19
  from .minesweeper import MinesweeperGame
18
20
  from .nonogram import NonogramGame
21
+ from .nqueens import NQueensGame
22
+ from .numberlink import NumberlinkGame
19
23
  from .nurikabe import NurikabeGame
24
+ from .rush_hour import RushHourCommandHandler, RushHourGame
20
25
  from .scheduler import SchedulerGame
21
26
  from .shikaku import ShikakuGame
27
+ from .skyscrapers import SkyscrapersGame
22
28
  from .slitherlink import SlitherlinkGame
23
29
  from .sokoban import SokobanGame
24
30
  from .star_battle import StarBattleGame
@@ -57,11 +63,21 @@ AVAILABLE_GAMES = {
57
63
  "nurikabe": NurikabeGame,
58
64
  "einstein": EinsteinGame,
59
65
  "minesweeper": MinesweeperGame,
66
+ # New Games
67
+ "skyscrapers": SkyscrapersGame,
68
+ "nqueens": NQueensGame,
69
+ "numberlink": NumberlinkGame,
70
+ "graph_coloring": GraphColoringGame,
71
+ "cryptarithmetic": CryptarithmeticGame,
72
+ "rush_hour": RushHourGame,
60
73
  }
61
74
 
62
75
  # Registry of game command handlers (games that have moved command handling out of server)
63
76
  GAME_COMMAND_HANDLERS = {
64
77
  "sudoku": SudokuCommandHandler,
78
+ "graph_coloring": GraphColoringCommandHandler,
79
+ "cryptarithmetic": CryptarithmeticCommandHandler,
80
+ "rush_hour": RushHourCommandHandler,
65
81
  }
66
82
 
67
83
  __all__ = [
@@ -89,6 +105,12 @@ __all__ = [
89
105
  "NurikabeGame",
90
106
  "EinsteinGame",
91
107
  "MinesweeperGame",
108
+ "SkyscrapersGame",
109
+ "NQueensGame",
110
+ "NumberlinkGame",
111
+ "GraphColoringGame",
112
+ "CryptarithmeticGame",
113
+ "RushHourGame",
92
114
  "AVAILABLE_GAMES",
93
115
  "GAME_COMMAND_HANDLERS",
94
116
  ]
@@ -358,6 +358,8 @@ class BinaryPuzzleGame(PuzzleGame):
358
358
  Returns:
359
359
  Tuple of (hint_data, hint_message) or None if puzzle is complete
360
360
  """
361
+ if not self.can_use_hint():
362
+ return None
361
363
  empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == -1]
362
364
  if not empty_cells:
363
365
  return None
@@ -415,6 +415,8 @@ class BridgesGame(PuzzleGame):
415
415
 
416
416
  async def get_hint(self) -> tuple[Any, str] | None:
417
417
  """Get a hint for the next move."""
418
+ if not self.can_use_hint():
419
+ return None
418
420
  # Find a bridge in the solution that's not yet placed correctly
419
421
  for bridge_key, solution_count in self.solution.items():
420
422
  current_count = self.bridges.get(bridge_key, 0)
@@ -0,0 +1,7 @@
1
+ """Cryptarithmetic puzzle game."""
2
+
3
+ from .commands import CryptarithmeticCommandHandler
4
+ from .config import CryptarithmeticConfig
5
+ from .game import CryptarithmeticGame
6
+
7
+ __all__ = ["CryptarithmeticGame", "CryptarithmeticConfig", "CryptarithmeticCommandHandler"]
@@ -0,0 +1,75 @@
1
+ """Command handler for Cryptarithmetic game."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ...models import GameCommand, MoveResult
6
+ from .._base import CommandResult, GameCommandHandler
7
+
8
+ if TYPE_CHECKING:
9
+ from .game import CryptarithmeticGame
10
+
11
+
12
+ class CryptarithmeticCommandHandler(GameCommandHandler):
13
+ """Handles commands for Cryptarithmetic game."""
14
+
15
+ game: "CryptarithmeticGame"
16
+
17
+ @property
18
+ def supported_commands(self) -> set[GameCommand]:
19
+ """Return the set of GameCommand enums this handler supports."""
20
+ return {GameCommand.ASSIGN, GameCommand.UNASSIGN}
21
+
22
+ async def handle_command(self, cmd: GameCommand, args: list[str]) -> CommandResult:
23
+ """Handle a Cryptarithmetic command.
24
+
25
+ Args:
26
+ cmd: The GameCommand enum value
27
+ args: List of string arguments (already split from input)
28
+
29
+ Returns:
30
+ CommandResult with the move result and display flags
31
+ """
32
+ if cmd == GameCommand.ASSIGN:
33
+ return await self._handle_assign(args)
34
+ elif cmd == GameCommand.UNASSIGN:
35
+ return await self._handle_unassign(args)
36
+ else:
37
+ return self.error_result(f"Unknown command: {cmd}")
38
+
39
+ async def _handle_assign(self, args: list[str]) -> CommandResult:
40
+ """Handle the ASSIGN command: assign <letter> <digit>."""
41
+ if len(args) != 2:
42
+ return CommandResult(
43
+ result=MoveResult(success=False, message="Usage: assign <letter> <digit>"),
44
+ should_display=False,
45
+ )
46
+
47
+ letter = args[0].upper()
48
+ digit = self.parse_int(args[1], "digit")
49
+
50
+ if digit is None:
51
+ return self.error_result("Digit must be an integer (0-9).")
52
+
53
+ result = await self.game.validate_move(letter, digit)
54
+
55
+ return CommandResult(
56
+ result=result,
57
+ should_display=result.success,
58
+ is_game_over=result.success and self.game.is_complete(),
59
+ )
60
+
61
+ async def _handle_unassign(self, args: list[str]) -> CommandResult:
62
+ """Handle the UNASSIGN command: unassign <letter>."""
63
+ if len(args) != 1:
64
+ return CommandResult(
65
+ result=MoveResult(success=False, message="Usage: unassign <letter>"),
66
+ should_display=False,
67
+ )
68
+
69
+ letter = args[0].upper()
70
+ result = await self.game.validate_move(letter, -1)
71
+
72
+ return CommandResult(
73
+ result=result,
74
+ should_display=result.success,
75
+ )
@@ -0,0 +1,23 @@
1
+ """Configuration for Cryptarithmetic puzzle game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models import DifficultyLevel
6
+
7
+
8
+ class CryptarithmeticConfig(BaseModel):
9
+ """Configuration for a Cryptarithmetic puzzle."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY)
12
+ max_word_length: int = Field(ge=2, le=6, description="Maximum word length")
13
+ pre_assigned: int = Field(ge=0, description="Number of pre-assigned letter-digit pairs")
14
+
15
+ @classmethod
16
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "CryptarithmeticConfig":
17
+ """Create config from difficulty level."""
18
+ config_map = {
19
+ DifficultyLevel.EASY: {"max_word_length": 3, "pre_assigned": 3},
20
+ DifficultyLevel.MEDIUM: {"max_word_length": 4, "pre_assigned": 2},
21
+ DifficultyLevel.HARD: {"max_word_length": 5, "pre_assigned": 0},
22
+ }
23
+ return cls(difficulty=difficulty, **config_map[difficulty])
@@ -0,0 +1,388 @@
1
+ """Cryptarithmetic puzzle game implementation."""
2
+
3
+ import string
4
+ from itertools import permutations
5
+ from typing import Any
6
+
7
+ from ...models import DifficultyLevel, DifficultyProfile, MoveResult
8
+ from .._base import PuzzleGame
9
+ from .config import CryptarithmeticConfig
10
+
11
+
12
+ class CryptarithmeticGame(PuzzleGame):
13
+ """Cryptarithmetic puzzle - map letters to digits to satisfy an addition equation.
14
+
15
+ Rules:
16
+ - Each letter represents a unique digit (0-9)
17
+ - The equation must be a valid addition (e.g., SEND + MORE = MONEY)
18
+ - No number may have a leading zero
19
+ - Each digit is used by at most one letter
20
+ """
21
+
22
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
23
+ super().__init__(difficulty, seed, **kwargs)
24
+ self.config = CryptarithmeticConfig.from_difficulty(self.difficulty)
25
+ self.operands: list[str] = []
26
+ self.result_word: str = ""
27
+ self.equation: str = ""
28
+ self.letters: list[str] = []
29
+ self.leading_letters: set[str] = set()
30
+ self.letter_mapping: dict[str, int] = {} # Solution
31
+ self.player_mapping: dict[str, int | None] = {} # Player's assignments
32
+ self.initial_mapping: dict[str, int] = {} # Pre-assigned
33
+
34
+ @property
35
+ def name(self) -> str:
36
+ return "Cryptarithmetic"
37
+
38
+ @property
39
+ def description(self) -> str:
40
+ return "Map letters to digits to make the addition equation work"
41
+
42
+ @property
43
+ def constraint_types(self) -> list[str]:
44
+ return ["arithmetic", "all_different", "carry_propagation", "bijective_mapping"]
45
+
46
+ @property
47
+ def business_analogies(self) -> list[str]:
48
+ return ["code_breaking", "financial_reconciliation", "auditing", "data_mapping"]
49
+
50
+ @property
51
+ def complexity_profile(self) -> dict[str, str]:
52
+ return {
53
+ "reasoning_type": "deductive",
54
+ "search_space": "large",
55
+ "constraint_density": "dense",
56
+ }
57
+
58
+ @property
59
+ def complexity_metrics(self) -> dict[str, int | float]:
60
+ unassigned = sum(1 for v in self.player_mapping.values() if v is None)
61
+ return {
62
+ "variable_count": len(self.letters),
63
+ "constraint_count": len(self.letters) + len(self.leading_letters) + 1,
64
+ "domain_size": 10,
65
+ "branching_factor": 10 - len(self.letters) / 2.0,
66
+ "empty_cells": unassigned,
67
+ }
68
+
69
+ @property
70
+ def difficulty_profile(self) -> DifficultyProfile:
71
+ profiles = {
72
+ DifficultyLevel.EASY: DifficultyProfile(
73
+ logic_depth=3, branching_factor=4.0, state_observability=1.0, constraint_density=0.7
74
+ ),
75
+ DifficultyLevel.MEDIUM: DifficultyProfile(
76
+ logic_depth=5, branching_factor=6.0, state_observability=1.0, constraint_density=0.6
77
+ ),
78
+ DifficultyLevel.HARD: DifficultyProfile(
79
+ logic_depth=7, branching_factor=8.0, state_observability=1.0, constraint_density=0.5
80
+ ),
81
+ }
82
+ return profiles[self.difficulty]
83
+
84
+ @property
85
+ def optimal_steps(self) -> int | None:
86
+ return len(self.letters) - len(self.initial_mapping)
87
+
88
+ def _word_to_number(self, word: str, mapping: dict[str, int]) -> int | None:
89
+ """Convert a word to a number using the letter-digit mapping."""
90
+ digits = []
91
+ for ch in word:
92
+ if ch not in mapping or mapping[ch] is None:
93
+ return None
94
+ digits.append(str(mapping[ch]))
95
+ return int("".join(digits))
96
+
97
+ def _verify_unique_solution(
98
+ self, letters: list[str], leading: set[str], operands: list[str], result_word: str
99
+ ) -> dict[str, int] | None:
100
+ """Brute-force verify the puzzle has a unique solution.
101
+
102
+ Returns the solution mapping if unique, None otherwise.
103
+ """
104
+ n = len(letters)
105
+ if n > 10:
106
+ return None
107
+
108
+ solutions = []
109
+ for perm in permutations(range(10), n):
110
+ mapping = dict(zip(letters, perm, strict=True))
111
+ # Check leading zeros
112
+ if any(mapping[ch] == 0 for ch in leading):
113
+ continue
114
+ # Check equation
115
+ operand_values = []
116
+ for word in operands:
117
+ val = 0
118
+ for ch in word:
119
+ val = val * 10 + mapping[ch]
120
+ operand_values.append(val)
121
+
122
+ result_val = 0
123
+ for ch in result_word:
124
+ result_val = result_val * 10 + mapping[ch]
125
+
126
+ if sum(operand_values) == result_val:
127
+ solutions.append(mapping.copy())
128
+ if len(solutions) > 1:
129
+ return None # Not unique
130
+
131
+ return solutions[0] if len(solutions) == 1 else None
132
+
133
+ async def generate_puzzle(self) -> None:
134
+ """Generate a cryptarithmetic puzzle."""
135
+ max_len = self.config.max_word_length
136
+ max_attempts = 200
137
+
138
+ for _ in range(max_attempts):
139
+ # Generate random numbers
140
+ min_val = 10 ** (max_len - 1)
141
+ max_val = 10**max_len - 1
142
+
143
+ num1 = self._rng.randint(min_val, max_val)
144
+ num2 = self._rng.randint(min_val, max_val)
145
+ total = num1 + num2
146
+
147
+ # Collect all digits used
148
+ all_digits_str = str(num1) + str(num2) + str(total)
149
+ unique_digits = sorted({int(d) for d in all_digits_str})
150
+
151
+ if len(unique_digits) < 4 or len(unique_digits) > 10:
152
+ continue
153
+
154
+ # Create letter mapping: digit -> letter
155
+ available_letters = list(string.ascii_uppercase)
156
+ self._rng.shuffle(available_letters)
157
+ digit_to_letter = {}
158
+ for i, digit in enumerate(unique_digits):
159
+ digit_to_letter[digit] = available_letters[i]
160
+
161
+ # Convert numbers to words
162
+ word1 = "".join(digit_to_letter[int(d)] for d in str(num1))
163
+ word2 = "".join(digit_to_letter[int(d)] for d in str(num2))
164
+ result = "".join(digit_to_letter[int(d)] for d in str(total))
165
+
166
+ # Build letter mapping (letter -> digit)
167
+ letter_mapping = {v: k for k, v in digit_to_letter.items()}
168
+ letters = sorted(letter_mapping.keys())
169
+ leading = {word1[0], word2[0], result[0]}
170
+
171
+ # Verify uniqueness
172
+ solution = self._verify_unique_solution(letters, leading, [word1, word2], result)
173
+ if solution is None:
174
+ continue
175
+
176
+ # Found a valid puzzle
177
+ self.operands = [word1, word2]
178
+ self.result_word = result
179
+ self.equation = f"{word1} + {word2} = {result}"
180
+ self.letters = letters
181
+ self.leading_letters = leading
182
+ self.letter_mapping = solution
183
+
184
+ # Initialize player mapping
185
+ self.player_mapping = dict.fromkeys(letters)
186
+
187
+ # Pre-assign some letters based on difficulty
188
+ pre_count = min(self.config.pre_assigned, len(letters))
189
+ pre_letters = letters[:]
190
+ self._rng.shuffle(pre_letters)
191
+ self.initial_mapping = {}
192
+ for ch in pre_letters[:pre_count]:
193
+ self.initial_mapping[ch] = solution[ch]
194
+ self.player_mapping[ch] = solution[ch]
195
+
196
+ self.game_started = True
197
+ return
198
+
199
+ # Fallback: hardcoded puzzle
200
+ self._generate_fallback()
201
+
202
+ def _generate_fallback(self) -> None:
203
+ """Generate a simple fallback puzzle."""
204
+ # AB + CD = EF where 12 + 34 = 46 -> not unique
205
+ # Use: TO + GO = OUT (89 + 78 = 167) ... not quite.
206
+ # Simple: 21 + 34 = 55 is not all-different.
207
+ # Use manually verified: IF + IT = AT -> not valid
208
+ # Simplest: use small known puzzles
209
+ self.operands = ["AB", "CD"]
210
+ self.result_word = "EFG"
211
+ # 98 + 76 = 174 -> A=9,B=8,C=7,D=6,E=1,F=7 -> F and C both 7, not unique letters
212
+ # Try: 57 + 48 = 105 -> A=5,B=7,C=4,D=8,E=1,F=0,G=5 -> A and G both 5
213
+ # Just use a known-good puzzle: 34 + 56 = 90 -> nope, only 5 unique
214
+ # Go with: AB + CB = DEA: 23 + 43 = 66 -> nope
215
+ # Simpler fallback strategy: just pick numbers and accept
216
+ self.operands = ["AB", "BA"]
217
+ self.result_word = "CDC"
218
+ # 12 + 21 = 33 -> nope (D=C)
219
+ # 13 + 31 = 44 -> nope
220
+ # 27 + 72 = 99 -> nope
221
+ # 19 + 91 = 110 -> A=1,B=9,C=1 -> conflict
222
+ # Just hardcode SEND+MORE=MONEY equivalent at small scale
223
+ # Use: 23 + 45 = 68 -> A=2,B=3,C=4,D=5,E=6,F=8 -> all unique, 6 letters
224
+ self.operands = ["AB", "CD"]
225
+ self.result_word = "EF"
226
+ self.letter_mapping = {"A": 2, "B": 3, "C": 4, "D": 5, "E": 6, "F": 8}
227
+ # Verify: 23 + 45 = 68 ✓
228
+ self.equation = "AB + CD = EF"
229
+ self.letters = sorted(self.letter_mapping.keys())
230
+ self.leading_letters = {"A", "C", "E"}
231
+ self.player_mapping = dict.fromkeys(self.letters)
232
+ self.initial_mapping = {}
233
+ self.game_started = True
234
+
235
+ async def validate_move(self, letter: str, digit: int) -> MoveResult:
236
+ """Validate assigning a digit to a letter.
237
+
238
+ Args:
239
+ letter: Uppercase letter
240
+ digit: 0-9 to assign, -1 to unassign
241
+ """
242
+ letter = letter.upper()
243
+
244
+ if letter not in self.letters:
245
+ self.record_move((letter,), False)
246
+ return MoveResult(
247
+ success=False,
248
+ message=f"Letter '{letter}' is not in this puzzle. Available: {', '.join(self.letters)}",
249
+ )
250
+
251
+ if letter in self.initial_mapping:
252
+ self.record_move((letter,), False)
253
+ return MoveResult(success=False, message=f"Letter '{letter}' is pre-assigned and cannot be changed.")
254
+
255
+ # Unassign
256
+ if digit == -1:
257
+ if self.player_mapping[letter] is None:
258
+ self.record_move((letter,), False)
259
+ return MoveResult(success=False, message=f"Letter '{letter}' is not assigned.")
260
+ self.player_mapping[letter] = None
261
+ self.record_move((letter,), True)
262
+ return MoveResult(success=True, message=f"Unassigned letter '{letter}'.", state_changed=True)
263
+
264
+ if not (0 <= digit <= 9):
265
+ self.record_move((letter,), False)
266
+ return MoveResult(success=False, message="Digit must be between 0 and 9.")
267
+
268
+ # Check leading zero constraint
269
+ if digit == 0 and letter in self.leading_letters:
270
+ self.record_move((letter,), False)
271
+ return MoveResult(
272
+ success=False,
273
+ message=f"Letter '{letter}' starts a word and cannot be 0 (no leading zeros).",
274
+ )
275
+
276
+ # Check if digit is already used by another letter
277
+ for other_letter, other_digit in self.player_mapping.items():
278
+ if other_digit == digit and other_letter != letter:
279
+ self.record_move((letter,), False)
280
+ return MoveResult(
281
+ success=False,
282
+ message=f"Digit {digit} is already assigned to letter '{other_letter}'.",
283
+ )
284
+
285
+ self.player_mapping[letter] = digit
286
+ self.record_move((letter,), True)
287
+ return MoveResult(success=True, message=f"Assigned {letter} = {digit}.", state_changed=True)
288
+
289
+ def is_complete(self) -> bool:
290
+ """Check if all letters are assigned and the equation holds."""
291
+ # Check all assigned
292
+ if any(v is None for v in self.player_mapping.values()):
293
+ return False
294
+
295
+ # Check equation
296
+ operand_values = []
297
+ for word in self.operands:
298
+ val = self._word_to_number(word, self.player_mapping)
299
+ if val is None:
300
+ return False
301
+ operand_values.append(val)
302
+
303
+ result_val = self._word_to_number(self.result_word, self.player_mapping)
304
+ if result_val is None:
305
+ return False
306
+
307
+ return sum(operand_values) == result_val
308
+
309
+ async def get_hint(self) -> tuple[Any, str] | None:
310
+ """Suggest a letter-digit assignment."""
311
+ if not self.can_use_hint():
312
+ return None
313
+ for letter in self.letters:
314
+ if self.player_mapping[letter] is None:
315
+ digit = self.letter_mapping[letter]
316
+ return (
317
+ (letter, digit),
318
+ f"Try assigning {letter} = {digit}.",
319
+ )
320
+ return None
321
+
322
+ def render_grid(self) -> str:
323
+ """Render the equation and current assignments."""
324
+ lines = []
325
+ lines.append(f"Equation: {self.equation}")
326
+ lines.append("")
327
+
328
+ # Show the equation with current assignments
329
+ def render_word(word: str) -> str:
330
+ chars = []
331
+ for ch in word:
332
+ val = self.player_mapping.get(ch)
333
+ if val is not None:
334
+ chars.append(str(val))
335
+ else:
336
+ chars.append(ch)
337
+ return "".join(chars)
338
+
339
+ rendered_operands = [render_word(w) for w in self.operands]
340
+ rendered_result = render_word(self.result_word)
341
+ lines.append(f" {' + '.join(rendered_operands)} = {rendered_result}")
342
+ lines.append("")
343
+
344
+ # Letter assignments table
345
+ lines.append("Assignments:")
346
+ for letter in self.letters:
347
+ val = self.player_mapping[letter]
348
+ prefix = "*" if letter in self.initial_mapping else " "
349
+ leading = " (leading)" if letter in self.leading_letters else ""
350
+ if val is not None:
351
+ lines.append(f" {prefix}{letter} = {val}{leading}")
352
+ else:
353
+ lines.append(f" {letter} = ?{leading}")
354
+
355
+ # Available digits
356
+ used_digits = {v for v in self.player_mapping.values() if v is not None}
357
+ available = [str(d) for d in range(10) if d not in used_digits]
358
+ lines.append(f"\nAvailable digits: {', '.join(available)}")
359
+
360
+ assigned = sum(1 for v in self.player_mapping.values() if v is not None)
361
+ lines.append(f"Assigned: {assigned}/{len(self.letters)}")
362
+
363
+ return "\n".join(lines)
364
+
365
+ def get_stats(self) -> str:
366
+ """Get current game statistics."""
367
+ assigned = sum(1 for v in self.player_mapping.values() if v is not None)
368
+ return f"Moves: {self.moves_made} | Assigned: {assigned}/{len(self.letters)} | Seed: {self.seed}"
369
+
370
+ def get_rules(self) -> str:
371
+ return (
372
+ "CRYPTARITHMETIC\n"
373
+ "Each letter represents a unique digit (0-9).\n"
374
+ "Find the digit for each letter so the addition equation is correct.\n"
375
+ "No number may have a leading zero.\n"
376
+ "Each digit is used by at most one letter."
377
+ )
378
+
379
+ def get_commands(self) -> str:
380
+ return (
381
+ "Commands:\n"
382
+ " assign <letter> <digit> - Assign a digit to a letter\n"
383
+ " unassign <letter> - Remove assignment\n"
384
+ " hint - Get a hint\n"
385
+ " check - Check if solved\n"
386
+ " show - Show current state\n"
387
+ " menu - Return to menu"
388
+ )
@@ -281,6 +281,8 @@ class EinsteinGame(PuzzleGame):
281
281
  Returns:
282
282
  Tuple of (hint_data, hint_message) or None
283
283
  """
284
+ if not self.can_use_hint():
285
+ return None
284
286
  # Find first unassigned attribute in solution
285
287
  for i in range(self.num_houses):
286
288
  for attr in ATTRIBUTES:
@@ -435,6 +435,8 @@ class FillominoGame(PuzzleGame):
435
435
  Returns:
436
436
  Tuple of (hint_data, hint_message) or None if puzzle is complete
437
437
  """
438
+ if not self.can_use_hint():
439
+ return None
438
440
  # Find an empty cell
439
441
  for r in range(self.size):
440
442
  for c in range(self.size):
@@ -288,6 +288,8 @@ class FutoshikiGame(PuzzleGame):
288
288
  Returns:
289
289
  Tuple of (hint_data, hint_message) or None if puzzle is complete
290
290
  """
291
+ if not self.can_use_hint():
292
+ return None
291
293
  empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0]
292
294
  if not empty_cells:
293
295
  return None
@@ -0,0 +1,7 @@
1
+ """Graph Coloring puzzle game."""
2
+
3
+ from .commands import GraphColoringCommandHandler
4
+ from .config import GraphColoringConfig
5
+ from .game import GraphColoringGame
6
+
7
+ __all__ = ["GraphColoringGame", "GraphColoringConfig", "GraphColoringCommandHandler"]