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.
- chuk_puzzles_gym/eval.py +21 -0
- chuk_puzzles_gym/games/__init__.py +22 -0
- chuk_puzzles_gym/games/binary/game.py +2 -0
- chuk_puzzles_gym/games/bridges/game.py +2 -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 +388 -0
- chuk_puzzles_gym/games/einstein/game.py +2 -0
- chuk_puzzles_gym/games/fillomino/game.py +2 -0
- chuk_puzzles_gym/games/futoshiki/game.py +2 -0
- chuk_puzzles_gym/games/graph_coloring/__init__.py +7 -0
- chuk_puzzles_gym/games/graph_coloring/commands.py +96 -0
- chuk_puzzles_gym/games/graph_coloring/config.py +24 -0
- chuk_puzzles_gym/games/graph_coloring/game.py +316 -0
- chuk_puzzles_gym/games/hidato/game.py +2 -0
- chuk_puzzles_gym/games/hitori/game.py +2 -0
- chuk_puzzles_gym/games/kakuro/game.py +2 -0
- chuk_puzzles_gym/games/kenken/game.py +2 -0
- chuk_puzzles_gym/games/killer_sudoku/game.py +2 -0
- chuk_puzzles_gym/games/knapsack/game.py +2 -0
- chuk_puzzles_gym/games/lights_out/game.py +2 -0
- chuk_puzzles_gym/games/logic_grid/game.py +2 -0
- chuk_puzzles_gym/games/mastermind/game.py +2 -0
- chuk_puzzles_gym/games/minesweeper/game.py +2 -0
- chuk_puzzles_gym/games/nonogram/game.py +2 -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 +321 -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 +344 -0
- chuk_puzzles_gym/games/nurikabe/game.py +2 -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 +479 -0
- chuk_puzzles_gym/games/rush_hour/models.py +15 -0
- chuk_puzzles_gym/games/scheduler/game.py +2 -0
- chuk_puzzles_gym/games/shikaku/game.py +2 -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 +282 -0
- chuk_puzzles_gym/games/slitherlink/game.py +2 -0
- chuk_puzzles_gym/games/sokoban/game.py +2 -0
- chuk_puzzles_gym/games/star_battle/game.py +2 -0
- chuk_puzzles_gym/games/sudoku/game.py +2 -0
- chuk_puzzles_gym/games/tents/game.py +2 -0
- chuk_puzzles_gym/server.py +18 -70
- chuk_puzzles_gym/trace/generator.py +87 -0
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/METADATA +60 -19
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/RECORD +55 -33
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/WHEEL +1 -1
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/entry_points.txt +0 -0
- {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):
|
|
@@ -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]
|
|
@@ -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,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])
|