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,96 @@
1
+ """Command handler for Graph Coloring game."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ...models import GameCommand, MoveResult
6
+ from .._base import CommandResult, GameCommandHandler
7
+ from .game import COLOR_NAMES
8
+
9
+ if TYPE_CHECKING:
10
+ from .game import GraphColoringGame
11
+
12
+ # Build a lookup from lowercase color name to color number
13
+ _COLOR_NAME_TO_NUM = {name.lower(): i + 1 for i, name in enumerate(COLOR_NAMES)}
14
+
15
+
16
+ class GraphColoringCommandHandler(GameCommandHandler):
17
+ """Handles commands for Graph Coloring game."""
18
+
19
+ game: "GraphColoringGame"
20
+
21
+ @property
22
+ def supported_commands(self) -> set[GameCommand]:
23
+ """Return the set of GameCommand enums this handler supports."""
24
+ return {GameCommand.PLACE, GameCommand.CLEAR}
25
+
26
+ async def handle_command(self, cmd: GameCommand, args: list[str]) -> CommandResult:
27
+ """Handle a Graph Coloring command.
28
+
29
+ Args:
30
+ cmd: The GameCommand enum value
31
+ args: List of string arguments (already split from input)
32
+
33
+ Returns:
34
+ CommandResult with the move result and display flags
35
+ """
36
+ if cmd == GameCommand.PLACE:
37
+ return await self._handle_place(args)
38
+ elif cmd == GameCommand.CLEAR:
39
+ return await self._handle_clear(args)
40
+ else:
41
+ return self.error_result(f"Unknown command: {cmd}")
42
+
43
+ def _parse_color(self, value: str) -> int | None:
44
+ """Parse a color argument as either an integer or a color name."""
45
+ # Try integer first
46
+ try:
47
+ return int(value)
48
+ except ValueError:
49
+ pass
50
+ # Try color name lookup
51
+ return _COLOR_NAME_TO_NUM.get(value.lower())
52
+
53
+ async def _handle_place(self, args: list[str]) -> CommandResult:
54
+ """Handle the PLACE command: place <node> <color>."""
55
+ if len(args) != 2:
56
+ return CommandResult(
57
+ result=MoveResult(success=False, message="Usage: place <node> <color>"),
58
+ should_display=False,
59
+ )
60
+
61
+ node = self.parse_int(args[0], "node")
62
+ color = self._parse_color(args[1])
63
+
64
+ if node is None:
65
+ return self.error_result("Node must be an integer.")
66
+ if color is None:
67
+ valid = ", ".join(f"{i + 1}={COLOR_NAMES[i]}" for i in range(self.game.num_colors))
68
+ return self.error_result(f"Invalid color. Use a number or name: {valid}")
69
+
70
+ result = await self.game.validate_move(node, color)
71
+
72
+ return CommandResult(
73
+ result=result,
74
+ should_display=result.success,
75
+ is_game_over=result.success and self.game.is_complete(),
76
+ )
77
+
78
+ async def _handle_clear(self, args: list[str]) -> CommandResult:
79
+ """Handle the CLEAR command: clear <node>."""
80
+ if len(args) != 1:
81
+ return CommandResult(
82
+ result=MoveResult(success=False, message="Usage: clear <node>"),
83
+ should_display=False,
84
+ )
85
+
86
+ node = self.parse_int(args[0], "node")
87
+
88
+ if node is None:
89
+ return self.error_result("Node must be an integer.")
90
+
91
+ result = await self.game.validate_move(node, 0)
92
+
93
+ return CommandResult(
94
+ result=result,
95
+ should_display=result.success,
96
+ )
@@ -0,0 +1,24 @@
1
+ """Configuration for Graph Coloring puzzle game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models import DifficultyLevel
6
+
7
+
8
+ class GraphColoringConfig(BaseModel):
9
+ """Configuration for a Graph Coloring puzzle."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY)
12
+ num_nodes: int = Field(ge=4, le=20, description="Number of nodes in the graph")
13
+ num_colors: int = Field(ge=2, le=8, description="Number of available colors")
14
+ edge_density: float = Field(ge=0.1, le=0.9, description="Probability of edge between nodes")
15
+
16
+ @classmethod
17
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "GraphColoringConfig":
18
+ """Create config from difficulty level."""
19
+ config_map = {
20
+ DifficultyLevel.EASY: {"num_nodes": 6, "num_colors": 3, "edge_density": 0.3},
21
+ DifficultyLevel.MEDIUM: {"num_nodes": 10, "num_colors": 4, "edge_density": 0.4},
22
+ DifficultyLevel.HARD: {"num_nodes": 15, "num_colors": 4, "edge_density": 0.5},
23
+ }
24
+ return cls(difficulty=difficulty, **config_map[difficulty])
@@ -0,0 +1,316 @@
1
+ """Graph Coloring puzzle game implementation."""
2
+
3
+ from collections import deque
4
+ from typing import Any
5
+
6
+ from ...models import DifficultyLevel, DifficultyProfile, MoveResult
7
+ from .._base import PuzzleGame
8
+ from .config import GraphColoringConfig
9
+
10
+ COLOR_NAMES = ["Red", "Blue", "Green", "Yellow", "Orange", "Purple", "Cyan", "Magenta"]
11
+
12
+
13
+ class GraphColoringGame(PuzzleGame):
14
+ """Graph Coloring puzzle - assign colors to nodes with no adjacent conflicts.
15
+
16
+ Rules:
17
+ - A graph has N nodes connected by edges
18
+ - Assign one of K colors to each node
19
+ - No two adjacent (connected) nodes may share the same color
20
+ """
21
+
22
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
23
+ super().__init__(difficulty, seed, **kwargs)
24
+ self.config = GraphColoringConfig.from_difficulty(self.difficulty)
25
+ self.num_nodes = self.config.num_nodes
26
+ self.num_colors = self.config.num_colors
27
+ self.edges: list[tuple[int, int]] = []
28
+ self.adjacency: dict[int, set[int]] = {}
29
+ self.coloring: dict[int, int] = {} # Player's assignment: node -> color (0=uncolored)
30
+ self.solution: dict[int, int] = {}
31
+ self.initial_coloring: dict[int, int] = {} # Pre-colored nodes
32
+ # Grid representation for server compatibility (adjacency matrix)
33
+ self.grid: list[list[int]] = []
34
+
35
+ @property
36
+ def name(self) -> str:
37
+ return "Graph Coloring"
38
+
39
+ @property
40
+ def description(self) -> str:
41
+ return "Color graph nodes so no adjacent nodes share a color"
42
+
43
+ @property
44
+ def constraint_types(self) -> list[str]:
45
+ return ["graph_coloring", "inequality", "global_constraint"]
46
+
47
+ @property
48
+ def business_analogies(self) -> list[str]:
49
+ return ["frequency_assignment", "exam_timetabling", "register_allocation", "zone_planning"]
50
+
51
+ @property
52
+ def complexity_profile(self) -> dict[str, str]:
53
+ return {
54
+ "reasoning_type": "deductive",
55
+ "search_space": "exponential",
56
+ "constraint_density": "moderate",
57
+ }
58
+
59
+ @property
60
+ def complexity_metrics(self) -> dict[str, int | float]:
61
+ uncolored = sum(1 for n in range(1, self.num_nodes + 1) if self.coloring.get(n, 0) == 0)
62
+ return {
63
+ "variable_count": self.num_nodes,
64
+ "constraint_count": len(self.edges),
65
+ "domain_size": self.num_colors,
66
+ "branching_factor": self.num_colors,
67
+ "empty_cells": uncolored,
68
+ }
69
+
70
+ @property
71
+ def difficulty_profile(self) -> DifficultyProfile:
72
+ profiles = {
73
+ DifficultyLevel.EASY: DifficultyProfile(
74
+ logic_depth=2, branching_factor=3.0, state_observability=1.0, constraint_density=0.5
75
+ ),
76
+ DifficultyLevel.MEDIUM: DifficultyProfile(
77
+ logic_depth=4, branching_factor=4.0, state_observability=1.0, constraint_density=0.5
78
+ ),
79
+ DifficultyLevel.HARD: DifficultyProfile(
80
+ logic_depth=6, branching_factor=4.0, state_observability=1.0, constraint_density=0.6
81
+ ),
82
+ }
83
+ return profiles[self.difficulty]
84
+
85
+ @property
86
+ def optimal_steps(self) -> int | None:
87
+ initial_colored = len(self.initial_coloring)
88
+ return self.num_nodes - initial_colored
89
+
90
+ def _ensure_connected(self) -> None:
91
+ """Add edges to make the graph connected if needed."""
92
+ # Find connected components using BFS
93
+ visited: set[int] = set()
94
+ components: list[set[int]] = []
95
+
96
+ for node in range(1, self.num_nodes + 1):
97
+ if node in visited:
98
+ continue
99
+ component: set[int] = set()
100
+ queue = deque([node])
101
+ while queue:
102
+ n = queue.popleft()
103
+ if n in visited:
104
+ continue
105
+ visited.add(n)
106
+ component.add(n)
107
+ for neighbor in self.adjacency.get(n, set()):
108
+ if neighbor not in visited:
109
+ queue.append(neighbor)
110
+ components.append(component)
111
+
112
+ # Connect components by adding edges between them
113
+ for i in range(1, len(components)):
114
+ # Pick a node from each component
115
+ node_a = self._rng.choice(list(components[i - 1]))
116
+ node_b = self._rng.choice(list(components[i]))
117
+ # Ensure different colors for the edge
118
+ if self.solution[node_a] == self.solution[node_b]:
119
+ # Swap one node's color with an unused color
120
+ for color in range(1, self.num_colors + 1):
121
+ if color != self.solution[node_a]:
122
+ conflict = False
123
+ for neighbor in self.adjacency.get(node_b, set()):
124
+ if self.solution.get(neighbor, 0) == color:
125
+ conflict = True
126
+ break
127
+ if not conflict:
128
+ self.solution[node_b] = color
129
+ break
130
+
131
+ edge = (min(node_a, node_b), max(node_a, node_b))
132
+ if edge not in set(self.edges):
133
+ self.edges.append(edge)
134
+ self.adjacency.setdefault(node_a, set()).add(node_b)
135
+ self.adjacency.setdefault(node_b, set()).add(node_a)
136
+ # Merge components
137
+ components[i] = components[i] | components[i - 1]
138
+
139
+ async def generate_puzzle(self) -> None:
140
+ """Generate a graph coloring puzzle."""
141
+ n = self.num_nodes
142
+ k = self.num_colors
143
+ density = self.config.edge_density
144
+
145
+ # Assign each node a random color (guarantees k-colorability)
146
+ self.solution = {}
147
+ for node in range(1, n + 1):
148
+ self.solution[node] = self._rng.randint(1, k)
149
+
150
+ # Generate edges: only between differently colored nodes
151
+ self.edges = []
152
+ self.adjacency = {node: set() for node in range(1, n + 1)}
153
+ for i in range(1, n + 1):
154
+ for j in range(i + 1, n + 1):
155
+ if self.solution[i] != self.solution[j]:
156
+ if self._rng.random() < density:
157
+ self.edges.append((i, j))
158
+ self.adjacency[i].add(j)
159
+ self.adjacency[j].add(i)
160
+
161
+ # Ensure graph is connected
162
+ self._ensure_connected()
163
+
164
+ # Pre-color some nodes based on difficulty
165
+ pre_color_map = {
166
+ DifficultyLevel.EASY: 2,
167
+ DifficultyLevel.MEDIUM: 1,
168
+ DifficultyLevel.HARD: 0,
169
+ }
170
+ num_pre = min(pre_color_map[self.difficulty], n)
171
+ nodes = list(range(1, n + 1))
172
+ self._rng.shuffle(nodes)
173
+ self.initial_coloring = {}
174
+ for node in nodes[:num_pre]:
175
+ self.initial_coloring[node] = self.solution[node]
176
+
177
+ # Initialize player coloring with pre-colored nodes
178
+ self.coloring = dict.fromkeys(range(1, n + 1), 0)
179
+ for node, color in self.initial_coloring.items():
180
+ self.coloring[node] = color
181
+
182
+ # Build adjacency matrix as grid for server compatibility
183
+ self.grid = [[0] * n for _ in range(n)]
184
+ for i, j in self.edges:
185
+ self.grid[i - 1][j - 1] = 1
186
+ self.grid[j - 1][i - 1] = 1
187
+
188
+ self.game_started = True
189
+
190
+ async def validate_move(self, node: int, color: int) -> MoveResult:
191
+ """Validate assigning a color to a node.
192
+
193
+ Args:
194
+ node: Node ID (1-indexed)
195
+ color: Color number (1-K) or 0 to clear
196
+ """
197
+ if not (1 <= node <= self.num_nodes):
198
+ self.record_move((node,), False)
199
+ return MoveResult(success=False, message=f"Node must be between 1 and {self.num_nodes}.")
200
+
201
+ if node in self.initial_coloring:
202
+ self.record_move((node,), False)
203
+ return MoveResult(success=False, message="Cannot modify a pre-colored node.")
204
+
205
+ if color == 0:
206
+ self.coloring[node] = 0
207
+ self.record_move((node,), True)
208
+ return MoveResult(success=True, message=f"Cleared color from node {node}.", state_changed=True)
209
+
210
+ if not (1 <= color <= self.num_colors):
211
+ self.record_move((node,), False)
212
+ return MoveResult(
213
+ success=False,
214
+ message=f"Color must be between 1 and {self.num_colors} ({', '.join(COLOR_NAMES[: self.num_colors])}).",
215
+ )
216
+
217
+ # Check for conflicts with adjacent nodes
218
+ for neighbor in self.adjacency.get(node, set()):
219
+ if self.coloring.get(neighbor, 0) == color:
220
+ self.record_move((node,), False)
221
+ color_name = COLOR_NAMES[color - 1] if color <= len(COLOR_NAMES) else str(color)
222
+ return MoveResult(
223
+ success=False,
224
+ message=f"Conflict: adjacent node {neighbor} already has color {color_name}.",
225
+ )
226
+
227
+ self.coloring[node] = color
228
+ self.record_move((node,), True)
229
+ color_name = COLOR_NAMES[color - 1] if color <= len(COLOR_NAMES) else str(color)
230
+ return MoveResult(success=True, message=f"Colored node {node} with {color_name}.", state_changed=True)
231
+
232
+ def is_complete(self) -> bool:
233
+ """Check if all nodes are colored with no conflicts."""
234
+ # All nodes must be colored
235
+ for node in range(1, self.num_nodes + 1):
236
+ if self.coloring.get(node, 0) == 0:
237
+ return False
238
+
239
+ # No adjacent nodes share a color
240
+ for i, j in self.edges:
241
+ if self.coloring[i] == self.coloring[j]:
242
+ return False
243
+
244
+ return True
245
+
246
+ async def get_hint(self) -> tuple[Any, str] | None:
247
+ """Suggest a node to color."""
248
+ if not self.can_use_hint():
249
+ return None
250
+ for node in range(1, self.num_nodes + 1):
251
+ if self.coloring.get(node, 0) == 0:
252
+ color = self.solution[node]
253
+ color_name = COLOR_NAMES[color - 1] if color <= len(COLOR_NAMES) else str(color)
254
+ return (
255
+ (node, color),
256
+ f"Try coloring node {node} with {color_name} (color {color}).",
257
+ )
258
+ return None
259
+
260
+ def render_grid(self) -> str:
261
+ """Render the graph structure and current coloring."""
262
+ lines = []
263
+ lines.append(f"Graph: {self.num_nodes} nodes, {len(self.edges)} edges, {self.num_colors} colors")
264
+ lines.append("")
265
+
266
+ # Color palette
267
+ palette = []
268
+ for i in range(1, self.num_colors + 1):
269
+ name = COLOR_NAMES[i - 1] if i <= len(COLOR_NAMES) else str(i)
270
+ palette.append(f"{i}={name}")
271
+ lines.append("Colors: " + ", ".join(palette))
272
+ lines.append("")
273
+
274
+ # Node coloring status
275
+ lines.append("Nodes:")
276
+ for node in range(1, self.num_nodes + 1):
277
+ color = self.coloring.get(node, 0)
278
+ neighbors = sorted(self.adjacency.get(node, set()))
279
+ adj_str = ", ".join(str(n) for n in neighbors)
280
+ if color > 0:
281
+ color_name = COLOR_NAMES[color - 1] if color <= len(COLOR_NAMES) else str(color)
282
+ prefix = "*" if node in self.initial_coloring else " "
283
+ lines.append(f" {prefix}{node:2d}: [{color_name:>7s}] adj: {adj_str}")
284
+ else:
285
+ lines.append(f" {node:2d}: [ ] adj: {adj_str}")
286
+
287
+ colored = sum(1 for n in range(1, self.num_nodes + 1) if self.coloring.get(n, 0) > 0)
288
+ lines.append(f"\nColored: {colored}/{self.num_nodes}")
289
+
290
+ return "\n".join(lines)
291
+
292
+ def get_stats(self) -> str:
293
+ """Get current game statistics."""
294
+ colored = sum(1 for n in range(1, self.num_nodes + 1) if self.coloring.get(n, 0) > 0)
295
+ return f"Moves: {self.moves_made} | Colored: {colored}/{self.num_nodes} | Edges: {len(self.edges)} | Seed: {self.seed}"
296
+
297
+ def get_rules(self) -> str:
298
+ return (
299
+ f"GRAPH COLORING ({self.num_nodes} nodes, {self.num_colors} colors)\n"
300
+ "Assign a color to each node in the graph.\n"
301
+ "No two connected (adjacent) nodes may share the same color.\n"
302
+ "Pre-colored nodes (marked with *) cannot be changed."
303
+ )
304
+
305
+ def get_commands(self) -> str:
306
+ color_map = ", ".join(f"{i + 1}={COLOR_NAMES[i]}" for i in range(self.num_colors))
307
+ return (
308
+ "Commands:\n"
309
+ f" place <node> <color> - Color a node (number or name)\n"
310
+ f" Colors: {color_map}\n"
311
+ " clear <node> - Remove color from a node\n"
312
+ " hint - Get a hint\n"
313
+ " check - Check if solved\n"
314
+ " show - Show current state\n"
315
+ " menu - Return to menu"
316
+ )
@@ -302,6 +302,8 @@ class HidatoGame(PuzzleGame):
302
302
  Returns:
303
303
  Tuple of (hint_data, hint_message) or None if puzzle is complete
304
304
  """
305
+ if not self.can_use_hint():
306
+ return None
305
307
  # Find an empty cell
306
308
  empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0]
307
309
  if not empty_cells:
@@ -395,6 +395,8 @@ class HitoriGame(PuzzleGame):
395
395
 
396
396
  async def get_hint(self) -> tuple[Any, str] | None:
397
397
  """Get a hint for the next move."""
398
+ if not self.can_use_hint():
399
+ return None
398
400
  # Find a cell that should be shaded but isn't, or vice versa
399
401
  for r in range(self.size):
400
402
  for c in range(self.size):
@@ -311,6 +311,8 @@ class KakuroGame(PuzzleGame):
311
311
  Returns:
312
312
  Tuple of (hint_data, hint_message) or None if puzzle is complete
313
313
  """
314
+ if not self.can_use_hint():
315
+ return None
314
316
  empty_cells = [
315
317
  (r, c)
316
318
  for r in range(self.size)
@@ -366,6 +366,8 @@ class KenKenGame(PuzzleGame):
366
366
  Returns:
367
367
  Tuple of (hint_data, hint_message) or None if puzzle is complete
368
368
  """
369
+ if not self.can_use_hint():
370
+ return None
369
371
  empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0]
370
372
  if not empty_cells:
371
373
  return None
@@ -395,6 +395,8 @@ class KillerSudokuGame(PuzzleGame):
395
395
  Returns:
396
396
  Tuple of (hint_data, hint_message) or None if puzzle is complete
397
397
  """
398
+ if not self.can_use_hint():
399
+ return None
398
400
  empty_cells = [(r, c) for r in range(9) for c in range(9) if self.grid[r][c] == 0]
399
401
  if not empty_cells:
400
402
  return None
@@ -255,6 +255,8 @@ class KnapsackGame(PuzzleGame):
255
255
  Returns:
256
256
  Tuple of (hint_data, hint_message) or None
257
257
  """
258
+ if not self.can_use_hint():
259
+ return None
258
260
  # Suggest selecting an item that's in the optimal solution but not selected
259
261
  for i in range(len(self.items)):
260
262
  if self.optimal_selection[i] and not self.selection[i]:
@@ -173,6 +173,8 @@ class LightsOutGame(PuzzleGame):
173
173
  Returns:
174
174
  Tuple of (hint_data, hint_message) or None if puzzle is complete
175
175
  """
176
+ if not self.can_use_hint():
177
+ return None
176
178
  # Find a cell in the solution that should be pressed
177
179
  for row in range(self.size):
178
180
  for col in range(self.size):
@@ -235,6 +235,8 @@ class LogicGridGame(PuzzleGame):
235
235
  Returns:
236
236
  Tuple of (hint_data, hint_message) or None if puzzle is complete
237
237
  """
238
+ if not self.can_use_hint():
239
+ return None
238
240
  # Find a connection that hasn't been marked
239
241
  for person in self.categories.person:
240
242
  attrs = self.solution[person]
@@ -211,6 +211,8 @@ class MastermindGame(PuzzleGame):
211
211
  Returns:
212
212
  Tuple of (hint_data, hint_message) or None if no hints available
213
213
  """
214
+ if not self.can_use_hint():
215
+ return None
214
216
  if self.is_complete():
215
217
  return None
216
218
 
@@ -310,6 +310,8 @@ class MinesweeperGame(PuzzleGame):
310
310
  Returns:
311
311
  Tuple of (hint_data, hint_message) or None
312
312
  """
313
+ if not self.can_use_hint():
314
+ return None
313
315
  if self.game_over:
314
316
  return None
315
317
 
@@ -192,6 +192,8 @@ class NonogramGame(PuzzleGame):
192
192
  Returns:
193
193
  Tuple of (hint_data, hint_message) or None if puzzle is complete
194
194
  """
195
+ if not self.can_use_hint():
196
+ return None
195
197
  unknown_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == -1]
196
198
  if not unknown_cells:
197
199
  return None
@@ -0,0 +1,6 @@
1
+ """N-Queens puzzle game."""
2
+
3
+ from .config import NQueensConfig
4
+ from .game import NQueensGame
5
+
6
+ __all__ = ["NQueensGame", "NQueensConfig"]
@@ -0,0 +1,23 @@
1
+ """Configuration for N-Queens puzzle game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models import DifficultyLevel
6
+
7
+
8
+ class NQueensConfig(BaseModel):
9
+ """Configuration for an N-Queens puzzle."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY)
12
+ size: int = Field(ge=4, le=20, description="Board size (N)")
13
+ pre_placed: int = Field(ge=0, description="Number of pre-placed queens as hints")
14
+
15
+ @classmethod
16
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "NQueensConfig":
17
+ """Create config from difficulty level."""
18
+ config_map = {
19
+ DifficultyLevel.EASY: {"size": 6, "pre_placed": 3},
20
+ DifficultyLevel.MEDIUM: {"size": 8, "pre_placed": 2},
21
+ DifficultyLevel.HARD: {"size": 12, "pre_placed": 1},
22
+ }
23
+ return cls(difficulty=difficulty, **config_map[difficulty])