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,502 @@
|
|
|
1
|
+
"""Killer Sudoku 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 KillerSudokuConfig
|
|
8
|
+
from .models import Cage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KillerSudokuGame(PuzzleGame):
|
|
12
|
+
"""Killer Sudoku puzzle game.
|
|
13
|
+
|
|
14
|
+
Combination of Sudoku and Kakuro - fill grid with 1-9
|
|
15
|
+
where regions sum to target values.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
19
|
+
"""Initialize a new Killer Sudoku game.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
difficulty: Game difficulty level (easy/medium/hard)
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
25
|
+
|
|
26
|
+
self.config = KillerSudokuConfig.from_difficulty(self.difficulty)
|
|
27
|
+
self.size = 9
|
|
28
|
+
self.grid = [[0 for _ in range(9)] for _ in range(9)]
|
|
29
|
+
self.solution = [[0 for _ in range(9)] for _ in range(9)]
|
|
30
|
+
self.initial_grid = [[0 for _ in range(9)] for _ in range(9)]
|
|
31
|
+
|
|
32
|
+
# Cages: list of Cage objects
|
|
33
|
+
self.cages: list[Cage] = []
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def name(self) -> str:
|
|
37
|
+
"""The display name of this puzzle type."""
|
|
38
|
+
return "Killer Sudoku"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def description(self) -> str:
|
|
42
|
+
"""A one-line description of this puzzle type."""
|
|
43
|
+
return "Sudoku + Kakuro - regions must sum to targets"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def constraint_types(self) -> list[str]:
|
|
47
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
48
|
+
return ["all_different", "cage_sums", "linear_constraints", "uniqueness"]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def business_analogies(self) -> list[str]:
|
|
52
|
+
"""Business problems this puzzle models."""
|
|
53
|
+
return ["grouped_constraints", "sum_budgeting", "allocation_with_quotas"]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
57
|
+
"""Complexity profile of this puzzle."""
|
|
58
|
+
return {"reasoning_type": "deductive", "search_space": "large", "constraint_density": "dense"}
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def optimal_steps(self) -> int | None:
|
|
62
|
+
"""Minimum steps = empty cells to fill."""
|
|
63
|
+
return sum(1 for r in range(9) for c in range(9) if self.grid[r][c] == 0)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
67
|
+
"""Difficulty characteristics for Killer Sudoku."""
|
|
68
|
+
from ...models import DifficultyLevel
|
|
69
|
+
|
|
70
|
+
empty = self.optimal_steps or 0
|
|
71
|
+
logic_depth = {
|
|
72
|
+
DifficultyLevel.EASY.value: 3,
|
|
73
|
+
DifficultyLevel.MEDIUM.value: 5,
|
|
74
|
+
DifficultyLevel.HARD.value: 7,
|
|
75
|
+
}.get(self.difficulty.value, 4)
|
|
76
|
+
branching = 2.5 + (empty / 81) * 4
|
|
77
|
+
density = 1.0 - (empty / 81)
|
|
78
|
+
return DifficultyProfile(
|
|
79
|
+
logic_depth=logic_depth,
|
|
80
|
+
branching_factor=round(branching, 1),
|
|
81
|
+
state_observability=1.0,
|
|
82
|
+
constraint_density=round(density, 2),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def is_valid_move(self, row: int, col: int, num: int, grid: list[list[int]] | None = None) -> bool:
|
|
86
|
+
"""Check if placing num at (row, col) is valid.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
row: Row index (0-indexed)
|
|
90
|
+
col: Column index (0-indexed)
|
|
91
|
+
num: Number to place (1-9)
|
|
92
|
+
grid: Grid to check against (defaults to self.grid)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True if the move is valid, False otherwise
|
|
96
|
+
"""
|
|
97
|
+
if grid is None:
|
|
98
|
+
grid = self.grid
|
|
99
|
+
|
|
100
|
+
# Check row uniqueness
|
|
101
|
+
for c in range(9):
|
|
102
|
+
if c != col and grid[row][c] == num:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
# Check column uniqueness
|
|
106
|
+
for r in range(9):
|
|
107
|
+
if r != row and grid[r][col] == num:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
# Check 3x3 box uniqueness
|
|
111
|
+
box_row, box_col = 3 * (row // 3), 3 * (col // 3)
|
|
112
|
+
for r in range(box_row, box_row + 3):
|
|
113
|
+
for c in range(box_col, box_col + 3):
|
|
114
|
+
if (r != row or c != col) and grid[r][c] == num:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
def solve(self, grid: list[list[int]]) -> bool:
|
|
120
|
+
"""Solve the Killer Sudoku puzzle using backtracking.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
grid: The Killer Sudoku grid to solve
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if solved, False otherwise
|
|
127
|
+
"""
|
|
128
|
+
for row in range(9):
|
|
129
|
+
for col in range(9):
|
|
130
|
+
if grid[row][col] == 0:
|
|
131
|
+
for num in range(1, 10):
|
|
132
|
+
if self.is_valid_move(row, col, num, grid):
|
|
133
|
+
grid[row][col] = num
|
|
134
|
+
|
|
135
|
+
# Check cage constraints
|
|
136
|
+
if self._check_cage_constraints(grid, row, col):
|
|
137
|
+
if self.solve(grid):
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
grid[row][col] = 0
|
|
141
|
+
|
|
142
|
+
return False
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
def _check_cage_constraints(self, grid: list[list[int]], row: int, col: int) -> bool:
|
|
146
|
+
"""Check if cage constraints are satisfied.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
grid: Current grid state
|
|
150
|
+
row: Row of the cell that was just filled
|
|
151
|
+
col: Column of the cell that was just filled
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if cage constraints are satisfied or could be satisfied
|
|
155
|
+
"""
|
|
156
|
+
# Find which cage contains this cell
|
|
157
|
+
for cage in self.cages:
|
|
158
|
+
if (row, col) not in cage.cells:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
# Get all values in the cage
|
|
162
|
+
cage_values = []
|
|
163
|
+
filled_count = 0
|
|
164
|
+
for r, c in cage.cells:
|
|
165
|
+
val = grid[r][c]
|
|
166
|
+
if val != 0:
|
|
167
|
+
cage_values.append(val)
|
|
168
|
+
filled_count += 1
|
|
169
|
+
|
|
170
|
+
# Check for duplicates within cage
|
|
171
|
+
if len(cage_values) != len(set(cage_values)):
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
# If cage is not fully filled, check if we haven't exceeded target
|
|
175
|
+
if filled_count < len(cage.cells):
|
|
176
|
+
current_sum = sum(cage_values)
|
|
177
|
+
if current_sum >= cage.target:
|
|
178
|
+
return False
|
|
179
|
+
else:
|
|
180
|
+
# All cells filled - check if sum matches target
|
|
181
|
+
if sum(cage_values) != cage.target:
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def _generate_cages(self) -> None:
|
|
187
|
+
"""Generate cages for the puzzle.
|
|
188
|
+
|
|
189
|
+
In Killer Sudoku, each cage must have unique values.
|
|
190
|
+
"""
|
|
191
|
+
used = [[False for _ in range(9)] for _ in range(9)]
|
|
192
|
+
self.cages = []
|
|
193
|
+
|
|
194
|
+
for row in range(9):
|
|
195
|
+
for col in range(9):
|
|
196
|
+
if used[row][col]:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
# Start a new cage
|
|
200
|
+
cage_size = self._rng.randint(2, 4) # 2-4 cells per cage
|
|
201
|
+
cells = [(row, col)]
|
|
202
|
+
cage_values = {self.solution[row][col]}
|
|
203
|
+
used[row][col] = True
|
|
204
|
+
|
|
205
|
+
# Try to add more cells (must have unique values)
|
|
206
|
+
for _ in range(cage_size - 1):
|
|
207
|
+
# Find adjacent unused cells with unique values
|
|
208
|
+
candidates = []
|
|
209
|
+
for r, c in cells:
|
|
210
|
+
for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
|
|
211
|
+
nr, nc = r + dr, c + dc
|
|
212
|
+
if 0 <= nr < 9 and 0 <= nc < 9 and not used[nr][nc]:
|
|
213
|
+
if (nr, nc) not in candidates:
|
|
214
|
+
# Check if value is unique in this cage
|
|
215
|
+
if self.solution[nr][nc] not in cage_values:
|
|
216
|
+
candidates.append((nr, nc))
|
|
217
|
+
|
|
218
|
+
if candidates:
|
|
219
|
+
nr, nc = self._rng.choice(candidates)
|
|
220
|
+
cells.append((nr, nc))
|
|
221
|
+
cage_values.add(self.solution[nr][nc])
|
|
222
|
+
used[nr][nc] = True
|
|
223
|
+
else:
|
|
224
|
+
# No valid adjacent cells available
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
# If cage is still size 1, try to merge with an adjacent cage
|
|
228
|
+
if len(cells) == 1:
|
|
229
|
+
r, c = cells[0]
|
|
230
|
+
cell_value = self.solution[r][c]
|
|
231
|
+
merged = False
|
|
232
|
+
for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
|
|
233
|
+
nr, nc = r + dr, c + dc
|
|
234
|
+
if 0 <= nr < 9 and 0 <= nc < 9:
|
|
235
|
+
for cage_idx, cage in enumerate(self.cages):
|
|
236
|
+
if (nr, nc) in cage.cells:
|
|
237
|
+
# Check if we can merge without duplicating values
|
|
238
|
+
cage_vals = {self.solution[cr][cc] for cr, cc in cage.cells}
|
|
239
|
+
if cell_value not in cage_vals:
|
|
240
|
+
new_cells = list(cage.cells)
|
|
241
|
+
new_cells.append((r, c))
|
|
242
|
+
new_sum = sum(self.solution[cr][cc] for cr, cc in new_cells)
|
|
243
|
+
self.cages[cage_idx] = Cage(cells=new_cells, operation=None, target=new_sum)
|
|
244
|
+
merged = True
|
|
245
|
+
break
|
|
246
|
+
if merged:
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
if not merged:
|
|
250
|
+
# Couldn't merge, add as size-1 cage
|
|
251
|
+
target_sum = self.solution[r][c]
|
|
252
|
+
self.cages.append(Cage(cells=cells, operation=None, target=target_sum))
|
|
253
|
+
else:
|
|
254
|
+
# Calculate target sum from solution
|
|
255
|
+
target_sum = sum(self.solution[r][c] for r, c in cells)
|
|
256
|
+
self.cages.append(Cage(cells=cells, operation=None, target=target_sum))
|
|
257
|
+
|
|
258
|
+
async def generate_puzzle(self) -> None:
|
|
259
|
+
"""Generate a new Killer Sudoku puzzle."""
|
|
260
|
+
# Generate a valid Sudoku solution
|
|
261
|
+
self.grid = [[0 for _ in range(9)] for _ in range(9)]
|
|
262
|
+
|
|
263
|
+
# Base valid Sudoku pattern
|
|
264
|
+
for row in range(9):
|
|
265
|
+
for col in range(9):
|
|
266
|
+
self.grid[row][col] = (row * 3 + row // 3 + col) % 9 + 1
|
|
267
|
+
|
|
268
|
+
# Shuffle rows within bands and columns within stacks to maintain validity
|
|
269
|
+
for band in range(3):
|
|
270
|
+
# Shuffle rows within this band
|
|
271
|
+
rows_in_band = [band * 3, band * 3 + 1, band * 3 + 2]
|
|
272
|
+
shuffled_rows = rows_in_band[:]
|
|
273
|
+
self._rng.shuffle(shuffled_rows)
|
|
274
|
+
# Swap rows
|
|
275
|
+
temp = [self.grid[shuffled_rows[0]][:], self.grid[shuffled_rows[1]][:], self.grid[shuffled_rows[2]][:]]
|
|
276
|
+
for i, r in enumerate(rows_in_band):
|
|
277
|
+
self.grid[r] = temp[i]
|
|
278
|
+
|
|
279
|
+
for stack in range(3):
|
|
280
|
+
# Shuffle columns within this stack
|
|
281
|
+
cols_in_stack = [stack * 3, stack * 3 + 1, stack * 3 + 2]
|
|
282
|
+
shuffled_cols = cols_in_stack[:]
|
|
283
|
+
self._rng.shuffle(shuffled_cols)
|
|
284
|
+
# Swap columns
|
|
285
|
+
for row in range(9):
|
|
286
|
+
col_temp = [
|
|
287
|
+
self.grid[row][shuffled_cols[0]],
|
|
288
|
+
self.grid[row][shuffled_cols[1]],
|
|
289
|
+
self.grid[row][shuffled_cols[2]],
|
|
290
|
+
]
|
|
291
|
+
for i, c in enumerate(cols_in_stack):
|
|
292
|
+
self.grid[row][c] = col_temp[i]
|
|
293
|
+
|
|
294
|
+
# Also shuffle digit mapping for more variety
|
|
295
|
+
digit_map = list(range(1, 10))
|
|
296
|
+
self._rng.shuffle(digit_map)
|
|
297
|
+
for row in range(9):
|
|
298
|
+
for col in range(9):
|
|
299
|
+
self.grid[row][col] = digit_map[self.grid[row][col] - 1]
|
|
300
|
+
|
|
301
|
+
self.solution = [row[:] for row in self.grid]
|
|
302
|
+
|
|
303
|
+
# Generate cages
|
|
304
|
+
self._generate_cages()
|
|
305
|
+
|
|
306
|
+
# Empty the grid (Killer Sudoku starts completely empty)
|
|
307
|
+
self.grid = [[0 for _ in range(9)] for _ in range(9)]
|
|
308
|
+
self.initial_grid = [row[:] for row in self.grid]
|
|
309
|
+
self.moves_made = 0
|
|
310
|
+
self.game_started = True
|
|
311
|
+
|
|
312
|
+
async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
|
|
313
|
+
"""Place a number on the grid.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
row: Row index (1-indexed, user-facing)
|
|
317
|
+
col: Column index (1-indexed, user-facing)
|
|
318
|
+
num: Number to place (1-9, or 0 to clear)
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
MoveResult indicating success or failure
|
|
322
|
+
"""
|
|
323
|
+
# Convert to 0-indexed
|
|
324
|
+
row -= 1
|
|
325
|
+
col -= 1
|
|
326
|
+
|
|
327
|
+
# Validate coordinates
|
|
328
|
+
if not (0 <= row < 9 and 0 <= col < 9):
|
|
329
|
+
return MoveResult(success=False, message="Invalid coordinates. Use row and column between 1-9.")
|
|
330
|
+
|
|
331
|
+
# Clear the cell
|
|
332
|
+
if num == 0:
|
|
333
|
+
self.grid[row][col] = 0
|
|
334
|
+
return MoveResult(success=True, message="Cell cleared.", state_changed=True)
|
|
335
|
+
|
|
336
|
+
# Validate number
|
|
337
|
+
if not (1 <= num <= 9):
|
|
338
|
+
return MoveResult(success=False, message="Invalid number. Use 1-9 or 0 to clear.")
|
|
339
|
+
|
|
340
|
+
# Check if the move is valid
|
|
341
|
+
old_value = self.grid[row][col]
|
|
342
|
+
self.grid[row][col] = num
|
|
343
|
+
|
|
344
|
+
if not self.is_valid_move(row, col, num):
|
|
345
|
+
self.grid[row][col] = old_value
|
|
346
|
+
return MoveResult(
|
|
347
|
+
success=False, message="Invalid move! This number already exists in the row, column, or box."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
self.moves_made += 1
|
|
351
|
+
return MoveResult(success=True, message="Number placed successfully!", state_changed=True)
|
|
352
|
+
|
|
353
|
+
def is_complete(self) -> bool:
|
|
354
|
+
"""Check if the puzzle is complete and correct."""
|
|
355
|
+
# Check all cells filled
|
|
356
|
+
for row in range(9):
|
|
357
|
+
for col in range(9):
|
|
358
|
+
if self.grid[row][col] == 0:
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
# Check Sudoku constraints (rows, columns, boxes)
|
|
362
|
+
for row in range(9):
|
|
363
|
+
if len(set(self.grid[row])) != 9:
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
for col in range(9):
|
|
367
|
+
column = [self.grid[row][col] for row in range(9)]
|
|
368
|
+
if len(set(column)) != 9:
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
for box_row in range(3):
|
|
372
|
+
for box_col in range(3):
|
|
373
|
+
box = []
|
|
374
|
+
for r in range(box_row * 3, box_row * 3 + 3):
|
|
375
|
+
for c in range(box_col * 3, box_col * 3 + 3):
|
|
376
|
+
box.append(self.grid[r][c])
|
|
377
|
+
if len(set(box)) != 9:
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
# Check all cages
|
|
381
|
+
for cage in self.cages:
|
|
382
|
+
cage_values = [self.grid[r][c] for r, c in cage.cells]
|
|
383
|
+
# Check for duplicates
|
|
384
|
+
if len(cage_values) != len(set(cage_values)):
|
|
385
|
+
return False
|
|
386
|
+
# Check sum
|
|
387
|
+
if sum(cage_values) != cage.target:
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
return True
|
|
391
|
+
|
|
392
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
393
|
+
"""Get a hint for the next move.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
397
|
+
"""
|
|
398
|
+
empty_cells = [(r, c) for r in range(9) for c in range(9) if self.grid[r][c] == 0]
|
|
399
|
+
if not empty_cells:
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
row, col = self._rng.choice(empty_cells)
|
|
403
|
+
hint_data = (row + 1, col + 1, self.solution[row][col])
|
|
404
|
+
hint_message = f"Try placing {self.solution[row][col]} at row {row + 1}, column {col + 1}"
|
|
405
|
+
return hint_data, hint_message
|
|
406
|
+
|
|
407
|
+
def render_grid(self) -> str:
|
|
408
|
+
"""Render the current puzzle state as ASCII art.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
String representation of the puzzle grid with cages
|
|
412
|
+
"""
|
|
413
|
+
lines = []
|
|
414
|
+
|
|
415
|
+
# Create a cage ID map
|
|
416
|
+
cage_map = {}
|
|
417
|
+
for cage_id, cage in enumerate(self.cages):
|
|
418
|
+
for r, c in cage.cells:
|
|
419
|
+
cage_map[(r, c)] = (cage_id, cage.target)
|
|
420
|
+
|
|
421
|
+
# Header
|
|
422
|
+
lines.append(" | 1 2 3 | 4 5 6 | 7 8 9 |")
|
|
423
|
+
lines.append(" +" + "-" * 7 + "+" + "-" * 7 + "+" + "-" * 7 + "+")
|
|
424
|
+
|
|
425
|
+
for row in range(9):
|
|
426
|
+
if row > 0 and row % 3 == 0:
|
|
427
|
+
lines.append(" +" + "-" * 7 + "+" + "-" * 7 + "+" + "-" * 7 + "+")
|
|
428
|
+
|
|
429
|
+
line = f"{row + 1} |"
|
|
430
|
+
for col in range(9):
|
|
431
|
+
if col > 0 and col % 3 == 0:
|
|
432
|
+
line += " |"
|
|
433
|
+
|
|
434
|
+
cell = self.grid[row][col]
|
|
435
|
+
if cell == 0:
|
|
436
|
+
# Show cage sum in top-left cell of each cage
|
|
437
|
+
cage_id, target_sum = cage_map.get((row, col), (None, None))
|
|
438
|
+
if cage_id is not None:
|
|
439
|
+
cage_cells = self.cages[cage_id].cells
|
|
440
|
+
if (row, col) == min(cage_cells):
|
|
441
|
+
line += f" {target_sum:2d}" if target_sum < 100 else f"{target_sum}"
|
|
442
|
+
else:
|
|
443
|
+
line += " ."
|
|
444
|
+
else:
|
|
445
|
+
line += " ."
|
|
446
|
+
else:
|
|
447
|
+
line += f" {cell}"
|
|
448
|
+
line += " |"
|
|
449
|
+
lines.append(line)
|
|
450
|
+
|
|
451
|
+
lines.append(" +" + "-" * 7 + "+" + "-" * 7 + "+" + "-" * 7 + "+")
|
|
452
|
+
|
|
453
|
+
# Show cage info
|
|
454
|
+
lines.append("\nCages (sum targets):")
|
|
455
|
+
for _i, cage in enumerate(self.cages[:10]): # Show first 10
|
|
456
|
+
cells_str = ", ".join(f"({r + 1},{c + 1})" for r, c in sorted(cage.cells)[:3])
|
|
457
|
+
if len(cage.cells) > 3:
|
|
458
|
+
cells_str += "..."
|
|
459
|
+
lines.append(f" {cage.target}: {cells_str}")
|
|
460
|
+
if len(self.cages) > 10:
|
|
461
|
+
lines.append(f" ... and {len(self.cages) - 10} more cages")
|
|
462
|
+
|
|
463
|
+
return "\n".join(lines)
|
|
464
|
+
|
|
465
|
+
def get_rules(self) -> str:
|
|
466
|
+
"""Get the rules description for Killer Sudoku.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Multi-line string describing the puzzle rules
|
|
470
|
+
"""
|
|
471
|
+
return """KILLER SUDOKU RULES:
|
|
472
|
+
- Fill 9×9 grid with digits 1-9
|
|
473
|
+
- No repeats in rows, columns, or 3×3 boxes
|
|
474
|
+
- Numbers in each cage must sum to the target
|
|
475
|
+
- No repeated digits within a cage"""
|
|
476
|
+
|
|
477
|
+
def get_commands(self) -> str:
|
|
478
|
+
"""Get the available commands for Killer Sudoku.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Multi-line string describing available commands
|
|
482
|
+
"""
|
|
483
|
+
return """KILLER SUDOKU COMMANDS:
|
|
484
|
+
place <row> <col> <num> - Place a number (e.g., 'place 1 2 4')
|
|
485
|
+
clear <row> <col> - Clear a cell
|
|
486
|
+
show - Display the current grid
|
|
487
|
+
hint - Get a hint for the next move
|
|
488
|
+
check - Check your progress
|
|
489
|
+
solve - Show the solution (ends game)
|
|
490
|
+
menu - Return to game selection
|
|
491
|
+
quit - Exit the server"""
|
|
492
|
+
|
|
493
|
+
def get_stats(self) -> str:
|
|
494
|
+
"""Get current game statistics.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
String with game stats
|
|
498
|
+
"""
|
|
499
|
+
empty = sum(1 for r in range(9) for c in range(9) if self.grid[r][c] == 0)
|
|
500
|
+
return (
|
|
501
|
+
f"Moves made: {self.moves_made} | Empty cells: {empty} | Total cages: {len(self.cages)} | Seed: {self.seed}"
|
|
502
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Killer Sudoku game models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Cage(BaseModel):
|
|
7
|
+
"""A cage in Killer Sudoku game.
|
|
8
|
+
|
|
9
|
+
Unlike KenKen cages, Killer Sudoku cages only use addition.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(frozen=True)
|
|
13
|
+
|
|
14
|
+
cells: list[tuple[int, int]] = Field(min_length=1, description="List of cell coordinates (0-indexed)")
|
|
15
|
+
target: int = Field(description="Target sum for the cage")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Configuration for Knapsack game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KnapsackConfig(BaseModel):
|
|
9
|
+
"""Configuration for Knapsack game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
num_items: int = Field(ge=1, le=20, description="Number of items")
|
|
13
|
+
max_weight: int = Field(ge=1, description="Maximum knapsack capacity")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "KnapsackConfig":
|
|
17
|
+
"""Create config from difficulty level."""
|
|
18
|
+
config_map = {
|
|
19
|
+
DifficultyLevel.EASY: {"num_items": 5, "max_weight": 20},
|
|
20
|
+
DifficultyLevel.MEDIUM: {"num_items": 8, "max_weight": 35},
|
|
21
|
+
DifficultyLevel.HARD: {"num_items": 12, "max_weight": 50},
|
|
22
|
+
}
|
|
23
|
+
params = config_map[difficulty]
|
|
24
|
+
return cls(difficulty=difficulty, **params)
|