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.
- chuk_puzzles_gym/eval.py +21 -0
- chuk_puzzles_gym/games/__init__.py +22 -0
- chuk_puzzles_gym/games/cryptarithmetic/__init__.py +7 -0
- chuk_puzzles_gym/games/cryptarithmetic/commands.py +75 -0
- chuk_puzzles_gym/games/cryptarithmetic/config.py +23 -0
- chuk_puzzles_gym/games/cryptarithmetic/game.py +383 -0
- chuk_puzzles_gym/games/graph_coloring/__init__.py +7 -0
- chuk_puzzles_gym/games/graph_coloring/commands.py +79 -0
- chuk_puzzles_gym/games/graph_coloring/config.py +24 -0
- chuk_puzzles_gym/games/graph_coloring/game.py +309 -0
- chuk_puzzles_gym/games/nqueens/__init__.py +6 -0
- chuk_puzzles_gym/games/nqueens/config.py +23 -0
- chuk_puzzles_gym/games/nqueens/game.py +316 -0
- chuk_puzzles_gym/games/numberlink/__init__.py +6 -0
- chuk_puzzles_gym/games/numberlink/config.py +23 -0
- chuk_puzzles_gym/games/numberlink/game.py +338 -0
- chuk_puzzles_gym/games/rush_hour/__init__.py +8 -0
- chuk_puzzles_gym/games/rush_hour/commands.py +57 -0
- chuk_puzzles_gym/games/rush_hour/config.py +25 -0
- chuk_puzzles_gym/games/rush_hour/game.py +475 -0
- chuk_puzzles_gym/games/rush_hour/models.py +15 -0
- chuk_puzzles_gym/games/skyscrapers/__init__.py +6 -0
- chuk_puzzles_gym/games/skyscrapers/config.py +22 -0
- chuk_puzzles_gym/games/skyscrapers/game.py +277 -0
- chuk_puzzles_gym/server.py +1 -1
- chuk_puzzles_gym/trace/generator.py +87 -0
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/METADATA +60 -19
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/RECORD +31 -9
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/WHEEL +1 -1
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.dist-info}/entry_points.txt +0 -0
- {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,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
|
+
)
|