chuk-puzzles-gym 0.9__py3-none-any.whl → 0.10__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 (31) hide show
  1. chuk_puzzles_gym/eval.py +21 -0
  2. chuk_puzzles_gym/games/__init__.py +22 -0
  3. chuk_puzzles_gym/games/cryptarithmetic/__init__.py +7 -0
  4. chuk_puzzles_gym/games/cryptarithmetic/commands.py +75 -0
  5. chuk_puzzles_gym/games/cryptarithmetic/config.py +23 -0
  6. chuk_puzzles_gym/games/cryptarithmetic/game.py +383 -0
  7. chuk_puzzles_gym/games/graph_coloring/__init__.py +7 -0
  8. chuk_puzzles_gym/games/graph_coloring/commands.py +79 -0
  9. chuk_puzzles_gym/games/graph_coloring/config.py +24 -0
  10. chuk_puzzles_gym/games/graph_coloring/game.py +309 -0
  11. chuk_puzzles_gym/games/nqueens/__init__.py +6 -0
  12. chuk_puzzles_gym/games/nqueens/config.py +23 -0
  13. chuk_puzzles_gym/games/nqueens/game.py +316 -0
  14. chuk_puzzles_gym/games/numberlink/__init__.py +6 -0
  15. chuk_puzzles_gym/games/numberlink/config.py +23 -0
  16. chuk_puzzles_gym/games/numberlink/game.py +338 -0
  17. chuk_puzzles_gym/games/rush_hour/__init__.py +8 -0
  18. chuk_puzzles_gym/games/rush_hour/commands.py +57 -0
  19. chuk_puzzles_gym/games/rush_hour/config.py +25 -0
  20. chuk_puzzles_gym/games/rush_hour/game.py +475 -0
  21. chuk_puzzles_gym/games/rush_hour/models.py +15 -0
  22. chuk_puzzles_gym/games/skyscrapers/__init__.py +6 -0
  23. chuk_puzzles_gym/games/skyscrapers/config.py +22 -0
  24. chuk_puzzles_gym/games/skyscrapers/game.py +277 -0
  25. chuk_puzzles_gym/server.py +1 -1
  26. chuk_puzzles_gym/trace/generator.py +87 -0
  27. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/METADATA +60 -19
  28. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/RECORD +31 -9
  29. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/WHEEL +1 -1
  30. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/entry_points.txt +0 -0
  31. {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/top_level.txt +0 -0
@@ -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,309 @@
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_rules(self) -> str:
293
+ return (
294
+ f"GRAPH COLORING ({self.num_nodes} nodes, {self.num_colors} colors)\n"
295
+ "Assign a color to each node in the graph.\n"
296
+ "No two connected (adjacent) nodes may share the same color.\n"
297
+ "Pre-colored nodes (marked with *) cannot be changed."
298
+ )
299
+
300
+ def get_commands(self) -> str:
301
+ return (
302
+ "Commands:\n"
303
+ f" place <node> <color> - Color a node (1-{self.num_colors})\n"
304
+ " clear <node> - Remove color from a node\n"
305
+ " hint - Get a hint\n"
306
+ " check - Check if solved\n"
307
+ " show - Show current state\n"
308
+ " menu - Return to menu"
309
+ )
@@ -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])
@@ -0,0 +1,316 @@
1
+ """N-Queens 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 NQueensConfig
8
+
9
+
10
+ class NQueensGame(PuzzleGame):
11
+ """N-Queens puzzle - place N queens on an NxN board with no conflicts.
12
+
13
+ Rules:
14
+ - Place exactly N queens on an NxN chessboard
15
+ - No two queens may share the same row, column, or diagonal
16
+ - Some queens may be pre-placed as hints
17
+ """
18
+
19
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
20
+ super().__init__(difficulty, seed, **kwargs)
21
+ self.config = NQueensConfig.from_difficulty(self.difficulty)
22
+ self.size = self.config.size
23
+ self.grid: list[list[int]] = []
24
+ self.solution: list[list[int]] = []
25
+ self.initial_grid: list[list[int]] = []
26
+ self._queen_cols: list[int] = [] # Solution: queen_cols[row] = col
27
+
28
+ @property
29
+ def name(self) -> str:
30
+ return "N-Queens"
31
+
32
+ @property
33
+ def description(self) -> str:
34
+ return f"Place {self.size} queens on a {self.size}x{self.size} board with no conflicts"
35
+
36
+ @property
37
+ def constraint_types(self) -> list[str]:
38
+ return ["placement", "attack_avoidance", "all_different", "diagonal_constraint"]
39
+
40
+ @property
41
+ def business_analogies(self) -> list[str]:
42
+ return ["non_conflicting_placement", "resource_allocation", "antenna_placement"]
43
+
44
+ @property
45
+ def complexity_profile(self) -> dict[str, str]:
46
+ return {
47
+ "reasoning_type": "deductive",
48
+ "search_space": "exponential",
49
+ "constraint_density": "moderate",
50
+ }
51
+
52
+ @property
53
+ def complexity_metrics(self) -> dict[str, int | float]:
54
+ queens_placed = sum(1 for row in self.grid for cell in row if cell == 1)
55
+ return {
56
+ "variable_count": self.size,
57
+ "constraint_count": self.size * 3, # row + col + diag constraints
58
+ "domain_size": self.size,
59
+ "branching_factor": self.size / 2.0,
60
+ "empty_cells": self.size - queens_placed,
61
+ }
62
+
63
+ @property
64
+ def difficulty_profile(self) -> DifficultyProfile:
65
+ profiles = {
66
+ DifficultyLevel.EASY: DifficultyProfile(
67
+ logic_depth=2, branching_factor=3.0, state_observability=1.0, constraint_density=0.5
68
+ ),
69
+ DifficultyLevel.MEDIUM: DifficultyProfile(
70
+ logic_depth=4, branching_factor=4.0, state_observability=1.0, constraint_density=0.4
71
+ ),
72
+ DifficultyLevel.HARD: DifficultyProfile(
73
+ logic_depth=6, branching_factor=6.0, state_observability=1.0, constraint_density=0.3
74
+ ),
75
+ }
76
+ return profiles[self.difficulty]
77
+
78
+ @property
79
+ def optimal_steps(self) -> int | None:
80
+ """Number of queens left to place."""
81
+ initial_queens = sum(1 for row in self.initial_grid for cell in row if cell == 1)
82
+ return self.size - initial_queens
83
+
84
+ def _solve_nqueens(self) -> list[int] | None:
85
+ """Find a valid N-Queens solution using randomized backtracking.
86
+
87
+ Returns:
88
+ List of column positions for each row, or None if no solution.
89
+ """
90
+ n = self.size
91
+ result: list[int] = [-1] * n
92
+ used_cols: set[int] = set()
93
+ diag1: set[int] = set() # row - col
94
+ diag2: set[int] = set() # row + col
95
+
96
+ # Create shuffled column order for each row (for randomization)
97
+ col_orders = []
98
+ for _ in range(n):
99
+ cols = list(range(n))
100
+ self._rng.shuffle(cols)
101
+ col_orders.append(cols)
102
+
103
+ def backtrack(row: int) -> bool:
104
+ if row == n:
105
+ return True
106
+ for col in col_orders[row]:
107
+ if col in used_cols:
108
+ continue
109
+ d1 = row - col
110
+ d2 = row + col
111
+ if d1 in diag1 or d2 in diag2:
112
+ continue
113
+ result[row] = col
114
+ used_cols.add(col)
115
+ diag1.add(d1)
116
+ diag2.add(d2)
117
+ if backtrack(row + 1):
118
+ return True
119
+ used_cols.discard(col)
120
+ diag1.discard(d1)
121
+ diag2.discard(d2)
122
+ return False
123
+
124
+ if backtrack(0):
125
+ return result
126
+ return None
127
+
128
+ def _has_conflicts(self) -> bool:
129
+ """Check if current grid has any queen conflicts."""
130
+ n = self.size
131
+ queens = []
132
+ for r in range(n):
133
+ for c in range(n):
134
+ if self.grid[r][c] == 1:
135
+ queens.append((r, c))
136
+
137
+ for i in range(len(queens)):
138
+ for j in range(i + 1, len(queens)):
139
+ r1, c1 = queens[i]
140
+ r2, c2 = queens[j]
141
+ if r1 == r2 or c1 == c2 or abs(r1 - r2) == abs(c1 - c2):
142
+ return True
143
+ return False
144
+
145
+ async def generate_puzzle(self) -> None:
146
+ """Generate an N-Queens puzzle."""
147
+ n = self.size
148
+ queen_cols = self._solve_nqueens()
149
+ if queen_cols is None:
150
+ raise RuntimeError(f"Failed to find N-Queens solution for N={n}")
151
+
152
+ self._queen_cols = queen_cols
153
+
154
+ # Build solution grid
155
+ self.solution = [[0] * n for _ in range(n)]
156
+ for r in range(n):
157
+ self.solution[r][queen_cols[r]] = 1
158
+
159
+ # Pre-place some queens as hints
160
+ self.grid = [[0] * n for _ in range(n)]
161
+ rows = list(range(n))
162
+ self._rng.shuffle(rows)
163
+ for r in rows[: self.config.pre_placed]:
164
+ self.grid[r][queen_cols[r]] = 1
165
+
166
+ self.initial_grid = [row[:] for row in self.grid]
167
+ self.game_started = True
168
+
169
+ async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
170
+ """Validate placing or removing a queen.
171
+
172
+ Args:
173
+ row: 1-indexed row
174
+ col: 1-indexed column
175
+ num: 1 to place queen, 0 to clear
176
+ """
177
+ n = self.size
178
+ r, c = row - 1, col - 1
179
+
180
+ if not (0 <= r < n and 0 <= c < n):
181
+ self.record_move((row, col), False)
182
+ return MoveResult(success=False, message=f"Position ({row}, {col}) is out of bounds.")
183
+
184
+ if self.initial_grid[r][c] == 1 and num == 0:
185
+ self.record_move((row, col), False)
186
+ return MoveResult(success=False, message="Cannot remove a pre-placed queen.")
187
+
188
+ if num == 0:
189
+ if self.grid[r][c] == 0:
190
+ self.record_move((row, col), False)
191
+ return MoveResult(success=False, message="No queen at that position.")
192
+ self.grid[r][c] = 0
193
+ self.record_move((row, col), True)
194
+ return MoveResult(success=True, message=f"Removed queen from ({row}, {col}).", state_changed=True)
195
+
196
+ if num != 1:
197
+ self.record_move((row, col), False)
198
+ return MoveResult(success=False, message="Use 1 to place a queen or 0 to clear.")
199
+
200
+ if self.grid[r][c] == 1:
201
+ self.record_move((row, col), False)
202
+ return MoveResult(success=False, message="A queen is already at that position.")
203
+
204
+ # Check conflicts with existing queens
205
+ for rr in range(n):
206
+ for cc in range(n):
207
+ if self.grid[rr][cc] == 1:
208
+ if rr == r:
209
+ self.record_move((row, col), False)
210
+ return MoveResult(
211
+ success=False,
212
+ message=f"Conflicts with queen at ({rr + 1}, {cc + 1}) - same row.",
213
+ )
214
+ if cc == c:
215
+ self.record_move((row, col), False)
216
+ return MoveResult(
217
+ success=False,
218
+ message=f"Conflicts with queen at ({rr + 1}, {cc + 1}) - same column.",
219
+ )
220
+ if abs(rr - r) == abs(cc - c):
221
+ self.record_move((row, col), False)
222
+ return MoveResult(
223
+ success=False,
224
+ message=f"Conflicts with queen at ({rr + 1}, {cc + 1}) - same diagonal.",
225
+ )
226
+
227
+ self.grid[r][c] = 1
228
+ self.record_move((row, col), True)
229
+ return MoveResult(success=True, message=f"Placed queen at ({row}, {col}).", state_changed=True)
230
+
231
+ def is_complete(self) -> bool:
232
+ """Check if N queens are placed with no conflicts."""
233
+ n = self.size
234
+ queens = []
235
+ for r in range(n):
236
+ for c in range(n):
237
+ if self.grid[r][c] == 1:
238
+ queens.append((r, c))
239
+
240
+ if len(queens) != n:
241
+ return False
242
+
243
+ # Verify no conflicts
244
+ cols = set()
245
+ diag1 = set()
246
+ diag2 = set()
247
+ for r, c in queens:
248
+ if c in cols or (r - c) in diag1 or (r + c) in diag2:
249
+ return False
250
+ cols.add(c)
251
+ diag1.add(r - c)
252
+ diag2.add(r + c)
253
+
254
+ return True
255
+
256
+ async def get_hint(self) -> tuple[Any, str] | None:
257
+ """Suggest the next queen to place from the solution."""
258
+ if not self.can_use_hint():
259
+ return None
260
+ n = self.size
261
+ for r in range(n):
262
+ c = self._queen_cols[r]
263
+ if self.grid[r][c] == 0:
264
+ return (
265
+ (r + 1, c + 1, 1),
266
+ f"Try placing a queen at row {r + 1}, column {c + 1}.",
267
+ )
268
+ return None
269
+
270
+ def render_grid(self) -> str:
271
+ """Render the chessboard with queens."""
272
+ n = self.size
273
+ lines = []
274
+
275
+ # Column headers
276
+ header = " " + " ".join(str(c + 1) for c in range(n))
277
+ lines.append(header)
278
+ lines.append(" " + "+" + "---" * n + "+")
279
+
280
+ for r in range(n):
281
+ cells = []
282
+ for c in range(n):
283
+ if self.grid[r][c] == 1:
284
+ if self.initial_grid[r][c] == 1:
285
+ cells.append("Q") # Pre-placed queen
286
+ else:
287
+ cells.append("Q") # Player-placed queen
288
+ else:
289
+ cells.append(".")
290
+ line = f" {r + 1} | " + " ".join(cells) + " |"
291
+ lines.append(line)
292
+
293
+ lines.append(" " + "+" + "---" * n + "+")
294
+ queens_placed = sum(1 for row in self.grid for cell in row if cell == 1)
295
+ lines.append(f"Queens: {queens_placed}/{n}")
296
+
297
+ return "\n".join(lines)
298
+
299
+ def get_rules(self) -> str:
300
+ return (
301
+ f"N-QUEENS ({self.size}x{self.size})\n"
302
+ f"Place {self.size} queens on the board.\n"
303
+ "No two queens may share the same row, column, or diagonal.\n"
304
+ "Pre-placed queens (Q) cannot be removed."
305
+ )
306
+
307
+ def get_commands(self) -> str:
308
+ return (
309
+ "Commands:\n"
310
+ " place <row> <col> 1 - Place a queen\n"
311
+ " clear <row> <col> - Remove a queen\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
+ )
@@ -0,0 +1,6 @@
1
+ """Numberlink (Flow) puzzle game."""
2
+
3
+ from .config import NumberlinkConfig
4
+ from .game import NumberlinkGame
5
+
6
+ __all__ = ["NumberlinkGame", "NumberlinkConfig"]