chuk-puzzles-gym 0.9__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/__init__.py +19 -0
- chuk_puzzles_gym/constants.py +9 -0
- chuk_puzzles_gym/eval.py +763 -0
- chuk_puzzles_gym/export/__init__.py +20 -0
- chuk_puzzles_gym/export/dataset.py +376 -0
- chuk_puzzles_gym/games/__init__.py +94 -0
- chuk_puzzles_gym/games/_base/__init__.py +6 -0
- chuk_puzzles_gym/games/_base/commands.py +91 -0
- chuk_puzzles_gym/games/_base/game.py +337 -0
- chuk_puzzles_gym/games/binary/__init__.py +6 -0
- chuk_puzzles_gym/games/binary/config.py +23 -0
- chuk_puzzles_gym/games/binary/game.py +434 -0
- chuk_puzzles_gym/games/bridges/__init__.py +6 -0
- chuk_puzzles_gym/games/bridges/config.py +24 -0
- chuk_puzzles_gym/games/bridges/game.py +489 -0
- chuk_puzzles_gym/games/einstein/__init__.py +6 -0
- chuk_puzzles_gym/games/einstein/config.py +23 -0
- chuk_puzzles_gym/games/einstein/constants.py +13 -0
- chuk_puzzles_gym/games/einstein/game.py +366 -0
- chuk_puzzles_gym/games/einstein/models.py +35 -0
- chuk_puzzles_gym/games/fillomino/__init__.py +6 -0
- chuk_puzzles_gym/games/fillomino/config.py +24 -0
- chuk_puzzles_gym/games/fillomino/game.py +516 -0
- chuk_puzzles_gym/games/futoshiki/__init__.py +6 -0
- chuk_puzzles_gym/games/futoshiki/config.py +23 -0
- chuk_puzzles_gym/games/futoshiki/game.py +391 -0
- chuk_puzzles_gym/games/hidato/__init__.py +6 -0
- chuk_puzzles_gym/games/hidato/config.py +24 -0
- chuk_puzzles_gym/games/hidato/game.py +403 -0
- chuk_puzzles_gym/games/hitori/__init__.py +6 -0
- chuk_puzzles_gym/games/hitori/config.py +23 -0
- chuk_puzzles_gym/games/hitori/game.py +451 -0
- chuk_puzzles_gym/games/kakuro/__init__.py +6 -0
- chuk_puzzles_gym/games/kakuro/config.py +24 -0
- chuk_puzzles_gym/games/kakuro/game.py +399 -0
- chuk_puzzles_gym/games/kenken/__init__.py +6 -0
- chuk_puzzles_gym/games/kenken/config.py +24 -0
- chuk_puzzles_gym/games/kenken/enums.py +13 -0
- chuk_puzzles_gym/games/kenken/game.py +486 -0
- chuk_puzzles_gym/games/kenken/models.py +15 -0
- chuk_puzzles_gym/games/killer_sudoku/__init__.py +6 -0
- chuk_puzzles_gym/games/killer_sudoku/config.py +23 -0
- chuk_puzzles_gym/games/killer_sudoku/game.py +502 -0
- chuk_puzzles_gym/games/killer_sudoku/models.py +15 -0
- chuk_puzzles_gym/games/knapsack/__init__.py +6 -0
- chuk_puzzles_gym/games/knapsack/config.py +24 -0
- chuk_puzzles_gym/games/knapsack/enums.py +10 -0
- chuk_puzzles_gym/games/knapsack/game.py +340 -0
- chuk_puzzles_gym/games/knapsack/models.py +13 -0
- chuk_puzzles_gym/games/lights_out/__init__.py +6 -0
- chuk_puzzles_gym/games/lights_out/config.py +24 -0
- chuk_puzzles_gym/games/lights_out/game.py +249 -0
- chuk_puzzles_gym/games/logic_grid/__init__.py +6 -0
- chuk_puzzles_gym/games/logic_grid/config.py +24 -0
- chuk_puzzles_gym/games/logic_grid/constants.py +12 -0
- chuk_puzzles_gym/games/logic_grid/game.py +333 -0
- chuk_puzzles_gym/games/logic_grid/models.py +24 -0
- chuk_puzzles_gym/games/mastermind/__init__.py +6 -0
- chuk_puzzles_gym/games/mastermind/config.py +25 -0
- chuk_puzzles_gym/games/mastermind/game.py +297 -0
- chuk_puzzles_gym/games/minesweeper/__init__.py +6 -0
- chuk_puzzles_gym/games/minesweeper/config.py +24 -0
- chuk_puzzles_gym/games/minesweeper/enums.py +12 -0
- chuk_puzzles_gym/games/minesweeper/game.py +432 -0
- chuk_puzzles_gym/games/nonogram/__init__.py +6 -0
- chuk_puzzles_gym/games/nonogram/config.py +23 -0
- chuk_puzzles_gym/games/nonogram/game.py +296 -0
- chuk_puzzles_gym/games/nurikabe/__init__.py +6 -0
- chuk_puzzles_gym/games/nurikabe/config.py +24 -0
- chuk_puzzles_gym/games/nurikabe/enums.py +14 -0
- chuk_puzzles_gym/games/nurikabe/game.py +586 -0
- chuk_puzzles_gym/games/scheduler/__init__.py +6 -0
- chuk_puzzles_gym/games/scheduler/config.py +25 -0
- chuk_puzzles_gym/games/scheduler/constants.py +15 -0
- chuk_puzzles_gym/games/scheduler/enums.py +10 -0
- chuk_puzzles_gym/games/scheduler/game.py +431 -0
- chuk_puzzles_gym/games/scheduler/models.py +14 -0
- chuk_puzzles_gym/games/shikaku/__init__.py +6 -0
- chuk_puzzles_gym/games/shikaku/config.py +24 -0
- chuk_puzzles_gym/games/shikaku/game.py +419 -0
- chuk_puzzles_gym/games/slitherlink/__init__.py +6 -0
- chuk_puzzles_gym/games/slitherlink/config.py +23 -0
- chuk_puzzles_gym/games/slitherlink/game.py +386 -0
- chuk_puzzles_gym/games/sokoban/__init__.py +6 -0
- chuk_puzzles_gym/games/sokoban/config.py +24 -0
- chuk_puzzles_gym/games/sokoban/game.py +671 -0
- chuk_puzzles_gym/games/star_battle/__init__.py +6 -0
- chuk_puzzles_gym/games/star_battle/config.py +24 -0
- chuk_puzzles_gym/games/star_battle/game.py +390 -0
- chuk_puzzles_gym/games/sudoku/__init__.py +7 -0
- chuk_puzzles_gym/games/sudoku/commands.py +96 -0
- chuk_puzzles_gym/games/sudoku/config.py +22 -0
- chuk_puzzles_gym/games/sudoku/game.py +328 -0
- chuk_puzzles_gym/games/tents/__init__.py +6 -0
- chuk_puzzles_gym/games/tents/config.py +24 -0
- chuk_puzzles_gym/games/tents/game.py +416 -0
- chuk_puzzles_gym/gym_env.py +465 -0
- chuk_puzzles_gym/models/__init__.py +47 -0
- chuk_puzzles_gym/models/base.py +30 -0
- chuk_puzzles_gym/models/config.py +11 -0
- chuk_puzzles_gym/models/enums.py +104 -0
- chuk_puzzles_gym/models/evaluation.py +487 -0
- chuk_puzzles_gym/models/games.py +12 -0
- chuk_puzzles_gym/server.py +1171 -0
- chuk_puzzles_gym/trace/__init__.py +10 -0
- chuk_puzzles_gym/trace/generator.py +726 -0
- chuk_puzzles_gym/utils/__init__.py +4 -0
- chuk_puzzles_gym-0.9.dist-info/METADATA +1471 -0
- chuk_puzzles_gym-0.9.dist-info/RECORD +112 -0
- chuk_puzzles_gym-0.9.dist-info/WHEEL +5 -0
- chuk_puzzles_gym-0.9.dist-info/entry_points.txt +4 -0
- chuk_puzzles_gym-0.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Kakuro puzzle game implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ...models import DifficultyProfile, MoveResult
|
|
6
|
+
from .._base import PuzzleGame
|
|
7
|
+
from .config import KakuroConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KakuroGame(PuzzleGame):
|
|
11
|
+
"""Kakuro (Cross Sums) puzzle game.
|
|
12
|
+
|
|
13
|
+
Like a crossword puzzle but with numbers. Each run (horizontal or vertical
|
|
14
|
+
sequence of white cells) must sum to the clue number, and no digit can repeat
|
|
15
|
+
within a run.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
19
|
+
"""Initialize a new Kakuro game.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
difficulty: Game difficulty level (easy=4x4, medium=6x6, hard=8x8)
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
25
|
+
|
|
26
|
+
# Use pydantic config based on difficulty
|
|
27
|
+
self.config = KakuroConfig.from_difficulty(self.difficulty)
|
|
28
|
+
self.size = self.config.size
|
|
29
|
+
|
|
30
|
+
# Grid: 0 = empty/playable, -1 = black cell
|
|
31
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
32
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
33
|
+
self.initial_grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
34
|
+
|
|
35
|
+
# Clues: list of (row, col, direction, sum, length)
|
|
36
|
+
# direction: 'h' for horizontal, 'v' for vertical
|
|
37
|
+
self.clues: list[tuple[int, int, str, int, int]] = []
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def name(self) -> str:
|
|
41
|
+
"""The display name of this puzzle type."""
|
|
42
|
+
return "Kakuro"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def description(self) -> str:
|
|
46
|
+
"""A one-line description of this puzzle type."""
|
|
47
|
+
return "Crossword math puzzle - fill runs with unique digits that sum to clues"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def constraint_types(self) -> list[str]:
|
|
51
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
52
|
+
return ["linear_sum", "all_different_in_run", "clue_satisfaction", "regional_constraints"]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def business_analogies(self) -> list[str]:
|
|
56
|
+
"""Business problems this puzzle models."""
|
|
57
|
+
return ["budget_allocation", "sum_constraints", "unique_distribution", "financial_planning"]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
61
|
+
"""Complexity profile of this puzzle."""
|
|
62
|
+
return {"reasoning_type": "deductive", "search_space": "medium", "constraint_density": "moderate"}
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def optimal_steps(self) -> int | None:
|
|
66
|
+
"""Minimum steps = white cells to fill."""
|
|
67
|
+
if not hasattr(self, "grid") or not self.grid:
|
|
68
|
+
return None
|
|
69
|
+
return sum(1 for r in range(len(self.grid)) for c in range(len(self.grid[0])) if self.grid[r][c] == 0)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
73
|
+
"""Difficulty characteristics for Kakuro."""
|
|
74
|
+
from ...models import DifficultyLevel
|
|
75
|
+
|
|
76
|
+
logic_depth = {
|
|
77
|
+
DifficultyLevel.EASY.value: 2,
|
|
78
|
+
DifficultyLevel.MEDIUM.value: 4,
|
|
79
|
+
DifficultyLevel.HARD.value: 5,
|
|
80
|
+
}.get(self.difficulty.value, 3)
|
|
81
|
+
return DifficultyProfile(
|
|
82
|
+
logic_depth=logic_depth,
|
|
83
|
+
branching_factor=3.0,
|
|
84
|
+
state_observability=1.0,
|
|
85
|
+
constraint_density=0.6,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _is_cell_in_run(self, row: int, col: int) -> bool:
|
|
89
|
+
"""Check if a cell is part of at least one run of length >= 2."""
|
|
90
|
+
if self.grid[row][col] == -1:
|
|
91
|
+
return True # Black cells don't need to be in runs
|
|
92
|
+
|
|
93
|
+
# Check horizontal run
|
|
94
|
+
h_count = 1
|
|
95
|
+
# Count left
|
|
96
|
+
c = col - 1
|
|
97
|
+
while c >= 0 and self.grid[row][c] != -1:
|
|
98
|
+
h_count += 1
|
|
99
|
+
c -= 1
|
|
100
|
+
# Count right
|
|
101
|
+
c = col + 1
|
|
102
|
+
while c < self.size and self.grid[row][c] != -1:
|
|
103
|
+
h_count += 1
|
|
104
|
+
c += 1
|
|
105
|
+
|
|
106
|
+
if h_count >= 2:
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
# Check vertical run
|
|
110
|
+
v_count = 1
|
|
111
|
+
# Count up
|
|
112
|
+
r = row - 1
|
|
113
|
+
while r >= 0 and self.grid[r][col] != -1:
|
|
114
|
+
v_count += 1
|
|
115
|
+
r -= 1
|
|
116
|
+
# Count down
|
|
117
|
+
r = row + 1
|
|
118
|
+
while r < self.size and self.grid[r][col] != -1:
|
|
119
|
+
v_count += 1
|
|
120
|
+
r += 1
|
|
121
|
+
|
|
122
|
+
return v_count >= 2
|
|
123
|
+
|
|
124
|
+
def _create_pattern(self) -> None:
|
|
125
|
+
"""Create a pattern of black and white cells ensuring all white cells are in runs."""
|
|
126
|
+
max_attempts = 50
|
|
127
|
+
|
|
128
|
+
for _attempt in range(max_attempts):
|
|
129
|
+
# Simple pattern: create some black cells
|
|
130
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
131
|
+
|
|
132
|
+
# Make top-left corner black (standard Kakuro pattern)
|
|
133
|
+
self.grid[0][0] = -1
|
|
134
|
+
|
|
135
|
+
# Add first row and first column as black (clue cells)
|
|
136
|
+
# This creates a more standard Kakuro layout
|
|
137
|
+
if self.size >= 4:
|
|
138
|
+
# Make first column mostly black for clues
|
|
139
|
+
for r in range(self.size):
|
|
140
|
+
if self._rng.random() < 0.5:
|
|
141
|
+
self.grid[r][0] = -1
|
|
142
|
+
|
|
143
|
+
# Add some random black cells to create runs
|
|
144
|
+
num_black = self.size // 2
|
|
145
|
+
for _ in range(num_black):
|
|
146
|
+
row = self._rng.randint(1, self.size - 1)
|
|
147
|
+
col = self._rng.randint(1, self.size - 1)
|
|
148
|
+
if self.grid[row][col] == 0:
|
|
149
|
+
self.grid[row][col] = -1
|
|
150
|
+
|
|
151
|
+
# Verify all white cells are in runs of length >= 2
|
|
152
|
+
all_valid = True
|
|
153
|
+
for r in range(self.size):
|
|
154
|
+
for c in range(self.size):
|
|
155
|
+
if self.grid[r][c] == 0 and not self._is_cell_in_run(r, c):
|
|
156
|
+
# This cell is orphaned - make it black
|
|
157
|
+
self.grid[r][c] = -1
|
|
158
|
+
|
|
159
|
+
# Re-check that we still have enough white cells
|
|
160
|
+
white_count = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
|
|
161
|
+
if white_count >= self.size * 2: # Need at least some playable cells
|
|
162
|
+
# Final verification
|
|
163
|
+
all_valid = True
|
|
164
|
+
for r in range(self.size):
|
|
165
|
+
for c in range(self.size):
|
|
166
|
+
if self.grid[r][c] == 0 and not self._is_cell_in_run(r, c):
|
|
167
|
+
all_valid = False
|
|
168
|
+
break
|
|
169
|
+
if not all_valid:
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
if all_valid:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
# Fallback: create a simple valid pattern
|
|
176
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
177
|
+
self.grid[0][0] = -1
|
|
178
|
+
# Make a simple cross pattern
|
|
179
|
+
mid = self.size // 2
|
|
180
|
+
self.grid[mid][mid] = -1
|
|
181
|
+
|
|
182
|
+
def _find_runs(self) -> list[tuple[int, int, str, list[tuple[int, int]]]]:
|
|
183
|
+
"""Find all runs (sequences of white cells) in the grid.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
List of (start_row, start_col, direction, cells) where cells is list of (row, col)
|
|
187
|
+
"""
|
|
188
|
+
runs = []
|
|
189
|
+
|
|
190
|
+
# Find horizontal runs
|
|
191
|
+
for row in range(self.size):
|
|
192
|
+
col = 0
|
|
193
|
+
while col < self.size:
|
|
194
|
+
if self.grid[row][col] != -1:
|
|
195
|
+
# Start of a run
|
|
196
|
+
start_col = col
|
|
197
|
+
cells = []
|
|
198
|
+
while col < self.size and self.grid[row][col] != -1:
|
|
199
|
+
cells.append((row, col))
|
|
200
|
+
col += 1
|
|
201
|
+
|
|
202
|
+
if len(cells) >= 2: # Only count runs of length 2+
|
|
203
|
+
runs.append((row, start_col, "h", cells))
|
|
204
|
+
else:
|
|
205
|
+
col += 1
|
|
206
|
+
|
|
207
|
+
# Find vertical runs
|
|
208
|
+
for col in range(self.size):
|
|
209
|
+
row = 0
|
|
210
|
+
while row < self.size:
|
|
211
|
+
if self.grid[row][col] != -1:
|
|
212
|
+
# Start of a run
|
|
213
|
+
start_row = row
|
|
214
|
+
cells = []
|
|
215
|
+
while row < self.size and self.grid[row][col] != -1:
|
|
216
|
+
cells.append((row, col))
|
|
217
|
+
row += 1
|
|
218
|
+
|
|
219
|
+
if len(cells) >= 2: # Only count runs of length 2+
|
|
220
|
+
runs.append((start_row, col, "v", cells))
|
|
221
|
+
else:
|
|
222
|
+
row += 1
|
|
223
|
+
|
|
224
|
+
return runs
|
|
225
|
+
|
|
226
|
+
async def generate_puzzle(self) -> None:
|
|
227
|
+
"""Generate a new Kakuro puzzle."""
|
|
228
|
+
# Create pattern
|
|
229
|
+
self._create_pattern()
|
|
230
|
+
|
|
231
|
+
# Find all runs
|
|
232
|
+
runs = self._find_runs()
|
|
233
|
+
|
|
234
|
+
# Generate solution for each run
|
|
235
|
+
self.solution = [row[:] for row in self.grid]
|
|
236
|
+
self.clues = []
|
|
237
|
+
|
|
238
|
+
for start_row, start_col, direction, cells in runs:
|
|
239
|
+
# Generate unique random digits for this run
|
|
240
|
+
run_length = len(cells)
|
|
241
|
+
digits = self._rng.sample(range(1, 10), run_length)
|
|
242
|
+
|
|
243
|
+
for (r, c), digit in zip(cells, digits, strict=True):
|
|
244
|
+
self.solution[r][c] = digit
|
|
245
|
+
|
|
246
|
+
# Create clue
|
|
247
|
+
clue_sum = sum(digits)
|
|
248
|
+
self.clues.append((start_row, start_col, direction, clue_sum, run_length))
|
|
249
|
+
|
|
250
|
+
# Empty the playable cells
|
|
251
|
+
for row in range(self.size):
|
|
252
|
+
for col in range(self.size):
|
|
253
|
+
if self.grid[row][col] != -1:
|
|
254
|
+
self.grid[row][col] = 0
|
|
255
|
+
|
|
256
|
+
self.initial_grid = [row[:] for row in self.grid]
|
|
257
|
+
self.moves_made = 0
|
|
258
|
+
self.game_started = True
|
|
259
|
+
|
|
260
|
+
async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
|
|
261
|
+
"""Place a number on the grid.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
row: Row index (1-indexed, user-facing)
|
|
265
|
+
col: Column index (1-indexed, user-facing)
|
|
266
|
+
num: Number to place (1-9, or 0 to clear)
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
MoveResult with success status and message
|
|
270
|
+
"""
|
|
271
|
+
# Convert to 0-indexed
|
|
272
|
+
row -= 1
|
|
273
|
+
col -= 1
|
|
274
|
+
|
|
275
|
+
# Validate coordinates
|
|
276
|
+
if not (0 <= row < self.size and 0 <= col < self.size):
|
|
277
|
+
return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
|
|
278
|
+
|
|
279
|
+
# Check if this is a black cell
|
|
280
|
+
if self.initial_grid[row][col] == -1:
|
|
281
|
+
return MoveResult(success=False, message="Cannot place numbers in black cells.")
|
|
282
|
+
|
|
283
|
+
# Clear the cell
|
|
284
|
+
if num == 0:
|
|
285
|
+
self.grid[row][col] = 0
|
|
286
|
+
return MoveResult(success=True, message="Cell cleared.", state_changed=True)
|
|
287
|
+
|
|
288
|
+
# Validate number
|
|
289
|
+
if not (1 <= num <= 9):
|
|
290
|
+
return MoveResult(success=False, message="Invalid number. Use 1-9 or 0 to clear.")
|
|
291
|
+
|
|
292
|
+
self.grid[row][col] = num
|
|
293
|
+
self.moves_made += 1
|
|
294
|
+
return MoveResult(success=True, message="Number placed successfully!", state_changed=True)
|
|
295
|
+
|
|
296
|
+
def is_complete(self) -> bool:
|
|
297
|
+
"""Check if the puzzle is complete and correct."""
|
|
298
|
+
# Check all white cells filled
|
|
299
|
+
for row in range(self.size):
|
|
300
|
+
for col in range(self.size):
|
|
301
|
+
if self.grid[row][col] == 0: # Empty white cell
|
|
302
|
+
return False
|
|
303
|
+
if self.grid[row][col] != -1 and self.grid[row][col] != self.solution[row][col]:
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
return True
|
|
307
|
+
|
|
308
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
309
|
+
"""Get a hint for the next move.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
313
|
+
"""
|
|
314
|
+
empty_cells = [
|
|
315
|
+
(r, c)
|
|
316
|
+
for r in range(self.size)
|
|
317
|
+
for c in range(self.size)
|
|
318
|
+
if self.grid[r][c] == 0 and self.solution[r][c] > 0 # Empty white cell with valid solution
|
|
319
|
+
]
|
|
320
|
+
if not empty_cells:
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
row, col = self._rng.choice(empty_cells)
|
|
324
|
+
hint_data = (row + 1, col + 1, self.solution[row][col])
|
|
325
|
+
hint_message = f"Try placing {self.solution[row][col]} at row {row + 1}, column {col + 1}"
|
|
326
|
+
return hint_data, hint_message
|
|
327
|
+
|
|
328
|
+
def render_grid(self) -> str:
|
|
329
|
+
"""Render the current puzzle state as ASCII art.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
String representation of the puzzle grid
|
|
333
|
+
"""
|
|
334
|
+
lines = []
|
|
335
|
+
|
|
336
|
+
# Header - align with row format "N |"
|
|
337
|
+
header = " |"
|
|
338
|
+
for i in range(self.size):
|
|
339
|
+
header += f" {i + 1} |"
|
|
340
|
+
lines.append(header)
|
|
341
|
+
lines.append(" +" + "---+" * self.size)
|
|
342
|
+
|
|
343
|
+
for row in range(self.size):
|
|
344
|
+
line = f"{row + 1} |"
|
|
345
|
+
for col in range(self.size):
|
|
346
|
+
if self.grid[row][col] == -1:
|
|
347
|
+
line += " ■ |"
|
|
348
|
+
else:
|
|
349
|
+
cell = self.grid[row][col]
|
|
350
|
+
cell_str = str(cell) if cell != 0 else "."
|
|
351
|
+
line += f" {cell_str} |"
|
|
352
|
+
lines.append(line)
|
|
353
|
+
lines.append(" +" + "---+" * self.size)
|
|
354
|
+
|
|
355
|
+
# Show clues
|
|
356
|
+
lines.append("\nClues:")
|
|
357
|
+
for start_row, start_col, direction, clue_sum, length in self.clues:
|
|
358
|
+
dir_str = "→" if direction == "h" else "↓"
|
|
359
|
+
lines.append(f" ({start_row + 1},{start_col + 1}) {dir_str} {clue_sum} ({length} cells)")
|
|
360
|
+
|
|
361
|
+
return "\n".join(lines)
|
|
362
|
+
|
|
363
|
+
def get_rules(self) -> str:
|
|
364
|
+
"""Get the rules description for Kakuro.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Multi-line string describing the puzzle rules
|
|
368
|
+
"""
|
|
369
|
+
return """KAKURO RULES:
|
|
370
|
+
- Fill white cells with 1-9
|
|
371
|
+
- Runs must sum to clue (→ horizontal, ↓ vertical)
|
|
372
|
+
- No repeats within a run
|
|
373
|
+
- Black cells (■) stay empty"""
|
|
374
|
+
|
|
375
|
+
def get_commands(self) -> str:
|
|
376
|
+
"""Get the available commands for Kakuro.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Multi-line string describing available commands
|
|
380
|
+
"""
|
|
381
|
+
return """KAKURO COMMANDS:
|
|
382
|
+
place <row> <col> <num> - Place a number (e.g., 'place 1 2 4')
|
|
383
|
+
clear <row> <col> - Clear a cell
|
|
384
|
+
show - Display the current grid
|
|
385
|
+
hint - Get a hint for the next move
|
|
386
|
+
check - Check your progress
|
|
387
|
+
solve - Show the solution (ends game)
|
|
388
|
+
menu - Return to game selection
|
|
389
|
+
quit - Exit the server"""
|
|
390
|
+
|
|
391
|
+
def get_stats(self) -> str:
|
|
392
|
+
"""Get current game statistics.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
String with game stats
|
|
396
|
+
"""
|
|
397
|
+
empty = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
|
|
398
|
+
total_white = sum(1 for r in range(self.size) for c in range(self.size) if self.initial_grid[r][c] != -1)
|
|
399
|
+
return f"Moves made: {self.moves_made} | Empty cells: {empty}/{total_white} | Seed: {self.seed}"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Configuration for KenKen game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KenKenConfig(BaseModel):
|
|
9
|
+
"""Configuration for KenKen game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
size: int = Field(ge=3, le=9, description="Grid size (NxN)")
|
|
13
|
+
num_cages: int = Field(ge=1, description="Number of cages")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "KenKenConfig":
|
|
17
|
+
"""Create config from difficulty level."""
|
|
18
|
+
config_map = {
|
|
19
|
+
DifficultyLevel.EASY: {"size": 4, "num_cages": 8},
|
|
20
|
+
DifficultyLevel.MEDIUM: {"size": 5, "num_cages": 12},
|
|
21
|
+
DifficultyLevel.HARD: {"size": 6, "num_cages": 18},
|
|
22
|
+
}
|
|
23
|
+
params = config_map[difficulty]
|
|
24
|
+
return cls(difficulty=difficulty, **params)
|