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,486 @@
|
|
|
1
|
+
"""KenKen 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 KenKenConfig
|
|
8
|
+
from .enums import ArithmeticOperation
|
|
9
|
+
from .models import Cage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KenKenGame(PuzzleGame):
|
|
13
|
+
"""KenKen (also known as Calcudoku or Mathdoku) puzzle game.
|
|
14
|
+
|
|
15
|
+
Similar to Sudoku but uses arithmetic cages with operations.
|
|
16
|
+
Each cage has a target number and an operation (+, -, *, /).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
20
|
+
"""Initialize a new KenKen game.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
difficulty: Game difficulty level (easy=4x4, medium=5x5, hard=6x6)
|
|
24
|
+
"""
|
|
25
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
26
|
+
|
|
27
|
+
# Grid size based on difficulty
|
|
28
|
+
self.config = KenKenConfig.from_difficulty(self.difficulty)
|
|
29
|
+
self.size = self.config.size
|
|
30
|
+
|
|
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
|
+
# Cages: list of Cage objects
|
|
36
|
+
self.cages: list[Cage] = []
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
"""The display name of this puzzle type."""
|
|
41
|
+
return "KenKen"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def description(self) -> str:
|
|
45
|
+
"""A one-line description of this puzzle type."""
|
|
46
|
+
return "Arithmetic cage puzzle - combine math and logic"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def constraint_types(self) -> list[str]:
|
|
50
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
51
|
+
return ["all_different", "arithmetic_cages", "operations", "multi_operation_constraints"]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def business_analogies(self) -> list[str]:
|
|
55
|
+
"""Business problems this puzzle models."""
|
|
56
|
+
return ["resource_groups", "operational_constraints", "mathematical_relationships", "grouped_calculations"]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
60
|
+
"""Complexity profile of this puzzle."""
|
|
61
|
+
return {"reasoning_type": "deductive", "search_space": "medium", "constraint_density": "moderate"}
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def optimal_steps(self) -> int | None:
|
|
65
|
+
"""Minimum steps = empty cells to fill."""
|
|
66
|
+
return sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
70
|
+
"""Difficulty characteristics for KenKen."""
|
|
71
|
+
from ...models import DifficultyLevel
|
|
72
|
+
|
|
73
|
+
empty = self.optimal_steps or 0
|
|
74
|
+
total = self.size * self.size
|
|
75
|
+
logic_depth = {
|
|
76
|
+
DifficultyLevel.EASY.value: 2,
|
|
77
|
+
DifficultyLevel.MEDIUM.value: 4,
|
|
78
|
+
DifficultyLevel.HARD.value: 5,
|
|
79
|
+
}.get(self.difficulty.value, 3)
|
|
80
|
+
branching = 2.0 + (empty / total) * 3
|
|
81
|
+
density = 1.0 - (empty / total) if total > 0 else 0.5
|
|
82
|
+
return DifficultyProfile(
|
|
83
|
+
logic_depth=logic_depth,
|
|
84
|
+
branching_factor=round(branching, 1),
|
|
85
|
+
state_observability=1.0,
|
|
86
|
+
constraint_density=round(density, 2),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def is_valid_move(self, row: int, col: int, num: int, grid: list[list[int]] | None = None) -> bool:
|
|
90
|
+
"""Check if placing num at (row, col) is valid.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
row: Row index (0-indexed)
|
|
94
|
+
col: Column index (0-indexed)
|
|
95
|
+
num: Number to place (1 to self.size)
|
|
96
|
+
grid: Grid to check against (defaults to self.grid)
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if the move is valid, False otherwise
|
|
100
|
+
"""
|
|
101
|
+
if grid is None:
|
|
102
|
+
grid = self.grid
|
|
103
|
+
|
|
104
|
+
# Check row uniqueness
|
|
105
|
+
for c in range(self.size):
|
|
106
|
+
if c != col and grid[row][c] == num:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
# Check column uniqueness
|
|
110
|
+
for r in range(self.size):
|
|
111
|
+
if r != row and grid[r][col] == num:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def solve(self, grid: list[list[int]]) -> bool:
|
|
117
|
+
"""Solve the KenKen puzzle using backtracking.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
grid: The KenKen grid to solve
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
True if solved, False otherwise
|
|
124
|
+
"""
|
|
125
|
+
for row in range(self.size):
|
|
126
|
+
for col in range(self.size):
|
|
127
|
+
if grid[row][col] == 0:
|
|
128
|
+
for num in range(1, self.size + 1):
|
|
129
|
+
grid[row][col] = num
|
|
130
|
+
|
|
131
|
+
if self.is_valid_move(row, col, num, grid) and self._check_cage_constraints(grid, row, col):
|
|
132
|
+
if self.solve(grid):
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
grid[row][col] = 0
|
|
136
|
+
|
|
137
|
+
return False
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def _check_cage_constraints(self, grid: list[list[int]], row: int, col: int) -> bool:
|
|
141
|
+
"""Check if the cage containing (row, col) is still valid.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
grid: Current grid state
|
|
145
|
+
row: Row of the cell that was just filled
|
|
146
|
+
col: Column of the cell that was just filled
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
True if cage constraints are satisfied or could be satisfied
|
|
150
|
+
"""
|
|
151
|
+
# Find which cage contains this cell
|
|
152
|
+
for cage in self.cages:
|
|
153
|
+
if (row, col) not in cage.cells:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# Get all values in the cage
|
|
157
|
+
cage_values = [grid[r][c] for r, c in cage.cells]
|
|
158
|
+
|
|
159
|
+
# If cage is not fully filled, we can only do partial checking
|
|
160
|
+
if 0 in cage_values:
|
|
161
|
+
# For now, allow partial fills (optimistic checking)
|
|
162
|
+
# More sophisticated pruning could be added here
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# All cells filled - check if operation gives target
|
|
166
|
+
if not self._evaluate_cage(cage_values, cage.operation, cage.target):
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
def _evaluate_cage(self, values: list[int], operation: ArithmeticOperation | None, target: int) -> bool:
|
|
172
|
+
"""Check if the cage operation evaluates to the target.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
values: List of values in the cage
|
|
176
|
+
operation: Operation to perform
|
|
177
|
+
target: Target value
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if operation on values equals target
|
|
181
|
+
"""
|
|
182
|
+
if operation is None or operation == ArithmeticOperation.NONE:
|
|
183
|
+
# Single cell cage
|
|
184
|
+
return len(values) == 1 and values[0] == target
|
|
185
|
+
|
|
186
|
+
if operation == ArithmeticOperation.ADD:
|
|
187
|
+
return sum(values) == target
|
|
188
|
+
|
|
189
|
+
if operation == ArithmeticOperation.MULTIPLY:
|
|
190
|
+
result = 1
|
|
191
|
+
for v in values:
|
|
192
|
+
result *= v
|
|
193
|
+
return result == target
|
|
194
|
+
|
|
195
|
+
if operation == ArithmeticOperation.SUBTRACT:
|
|
196
|
+
# Subtraction: target = larger - smaller (for 2 cells)
|
|
197
|
+
if len(values) != 2:
|
|
198
|
+
return False
|
|
199
|
+
return abs(values[0] - values[1]) == target
|
|
200
|
+
|
|
201
|
+
if operation == ArithmeticOperation.DIVIDE:
|
|
202
|
+
# Division: target = larger / smaller (for 2 cells)
|
|
203
|
+
if len(values) != 2:
|
|
204
|
+
return False
|
|
205
|
+
a, b = sorted(values, reverse=True)
|
|
206
|
+
return b != 0 and a % b == 0 and a // b == target
|
|
207
|
+
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
def _generate_cages(self) -> None:
|
|
211
|
+
"""Generate cages for the puzzle."""
|
|
212
|
+
# Simple cage generation: create random connected regions
|
|
213
|
+
used = [[False for _ in range(self.size)] for _ in range(self.size)]
|
|
214
|
+
self.cages = []
|
|
215
|
+
|
|
216
|
+
for row in range(self.size):
|
|
217
|
+
for col in range(self.size):
|
|
218
|
+
if used[row][col]:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Start a new cage
|
|
222
|
+
cage_size = self._rng.randint(1, 3) # 1-3 cells per cage
|
|
223
|
+
cells = [(row, col)]
|
|
224
|
+
used[row][col] = True
|
|
225
|
+
|
|
226
|
+
# Try to add more cells
|
|
227
|
+
for _ in range(cage_size - 1):
|
|
228
|
+
# Find adjacent unused cells
|
|
229
|
+
candidates = []
|
|
230
|
+
for r, c in cells:
|
|
231
|
+
for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
|
|
232
|
+
nr, nc = r + dr, c + dc
|
|
233
|
+
if 0 <= nr < self.size and 0 <= nc < self.size and not used[nr][nc]:
|
|
234
|
+
candidates.append((nr, nc))
|
|
235
|
+
|
|
236
|
+
if candidates:
|
|
237
|
+
nr, nc = self._rng.choice(candidates)
|
|
238
|
+
cells.append((nr, nc))
|
|
239
|
+
used[nr][nc] = True
|
|
240
|
+
|
|
241
|
+
# Determine operation and target from solution
|
|
242
|
+
cage_values = [self.solution[r][c] for r, c in cells]
|
|
243
|
+
|
|
244
|
+
if len(cells) == 1:
|
|
245
|
+
operation = None
|
|
246
|
+
target = cage_values[0]
|
|
247
|
+
else:
|
|
248
|
+
# Choose operation based on cage size
|
|
249
|
+
if len(cells) == 2:
|
|
250
|
+
operations = [
|
|
251
|
+
ArithmeticOperation.ADD,
|
|
252
|
+
ArithmeticOperation.SUBTRACT,
|
|
253
|
+
ArithmeticOperation.MULTIPLY,
|
|
254
|
+
ArithmeticOperation.DIVIDE,
|
|
255
|
+
]
|
|
256
|
+
else:
|
|
257
|
+
operations = [ArithmeticOperation.ADD, ArithmeticOperation.MULTIPLY]
|
|
258
|
+
|
|
259
|
+
operation = self._rng.choice(operations)
|
|
260
|
+
|
|
261
|
+
if operation == ArithmeticOperation.ADD:
|
|
262
|
+
target = sum(cage_values)
|
|
263
|
+
elif operation == ArithmeticOperation.MULTIPLY:
|
|
264
|
+
target = 1
|
|
265
|
+
for v in cage_values:
|
|
266
|
+
target *= v
|
|
267
|
+
elif operation == ArithmeticOperation.SUBTRACT:
|
|
268
|
+
target = abs(cage_values[0] - cage_values[1])
|
|
269
|
+
elif operation == ArithmeticOperation.DIVIDE:
|
|
270
|
+
a, b = sorted(cage_values, reverse=True)
|
|
271
|
+
if b == 0 or a % b != 0:
|
|
272
|
+
# Fallback to addition if division doesn't work
|
|
273
|
+
operation = ArithmeticOperation.ADD
|
|
274
|
+
target = sum(cage_values)
|
|
275
|
+
else:
|
|
276
|
+
target = a // b
|
|
277
|
+
|
|
278
|
+
self.cages.append(Cage(cells=cells, operation=operation, target=target))
|
|
279
|
+
|
|
280
|
+
async def generate_puzzle(self) -> None:
|
|
281
|
+
"""Generate a new KenKen puzzle."""
|
|
282
|
+
# Generate a valid Latin square as solution
|
|
283
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
284
|
+
|
|
285
|
+
# Simple solution generation: shifted rows
|
|
286
|
+
for row in range(self.size):
|
|
287
|
+
for col in range(self.size):
|
|
288
|
+
self.grid[row][col] = (row + col) % self.size + 1
|
|
289
|
+
|
|
290
|
+
# Shuffle rows and columns to make it more random
|
|
291
|
+
row_order = list(range(self.size))
|
|
292
|
+
col_order = list(range(self.size))
|
|
293
|
+
self._rng.shuffle(row_order)
|
|
294
|
+
self._rng.shuffle(col_order)
|
|
295
|
+
|
|
296
|
+
shuffled = [[self.grid[row_order[r]][col_order[c]] for c in range(self.size)] for r in range(self.size)]
|
|
297
|
+
self.solution = shuffled
|
|
298
|
+
|
|
299
|
+
# Generate cages
|
|
300
|
+
self._generate_cages()
|
|
301
|
+
|
|
302
|
+
# Empty the grid (KenKen starts completely empty)
|
|
303
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
304
|
+
self.initial_grid = [row[:] for row in self.grid]
|
|
305
|
+
self.moves_made = 0
|
|
306
|
+
self.game_started = True
|
|
307
|
+
|
|
308
|
+
async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
|
|
309
|
+
"""Place a number on the grid.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
row: Row index (1-indexed, user-facing)
|
|
313
|
+
col: Column index (1-indexed, user-facing)
|
|
314
|
+
num: Number to place (1 to self.size, or 0 to clear)
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
MoveResult indicating success/failure and message
|
|
318
|
+
"""
|
|
319
|
+
# Convert to 0-indexed
|
|
320
|
+
row -= 1
|
|
321
|
+
col -= 1
|
|
322
|
+
|
|
323
|
+
# Validate coordinates
|
|
324
|
+
if not (0 <= row < self.size and 0 <= col < self.size):
|
|
325
|
+
return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
|
|
326
|
+
|
|
327
|
+
# Clear the cell
|
|
328
|
+
if num == 0:
|
|
329
|
+
self.grid[row][col] = 0
|
|
330
|
+
return MoveResult(success=True, message="Cell cleared.", state_changed=True)
|
|
331
|
+
|
|
332
|
+
# Validate number
|
|
333
|
+
if not (1 <= num <= self.size):
|
|
334
|
+
return MoveResult(success=False, message=f"Invalid number. Use 1-{self.size} or 0 to clear.")
|
|
335
|
+
|
|
336
|
+
# Check if the move is valid
|
|
337
|
+
old_value = self.grid[row][col]
|
|
338
|
+
self.grid[row][col] = num
|
|
339
|
+
|
|
340
|
+
if not self.is_valid_move(row, col, num):
|
|
341
|
+
self.grid[row][col] = old_value
|
|
342
|
+
return MoveResult(success=False, message="Invalid move! This number already exists in the row or column.")
|
|
343
|
+
|
|
344
|
+
self.moves_made += 1
|
|
345
|
+
return MoveResult(success=True, message="Number placed successfully!", state_changed=True)
|
|
346
|
+
|
|
347
|
+
def is_complete(self) -> bool:
|
|
348
|
+
"""Check if the puzzle is complete and correct."""
|
|
349
|
+
# Check all cells filled
|
|
350
|
+
for row in range(self.size):
|
|
351
|
+
for col in range(self.size):
|
|
352
|
+
if self.grid[row][col] == 0:
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
# Check all cages
|
|
356
|
+
for cage in self.cages:
|
|
357
|
+
cage_values = [self.grid[r][c] for r, c in cage.cells]
|
|
358
|
+
if not self._evaluate_cage(cage_values, cage.operation, cage.target):
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
364
|
+
"""Get a hint for the next move.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
368
|
+
"""
|
|
369
|
+
empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0]
|
|
370
|
+
if not empty_cells:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
row, col = self._rng.choice(empty_cells)
|
|
374
|
+
hint_data = (row + 1, col + 1, self.solution[row][col])
|
|
375
|
+
hint_message = f"Try placing {self.solution[row][col]} at row {row + 1}, column {col + 1}"
|
|
376
|
+
return hint_data, hint_message
|
|
377
|
+
|
|
378
|
+
def render_grid(self) -> str:
|
|
379
|
+
"""Render the current puzzle state as ASCII art.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
String representation of the puzzle grid with cages
|
|
383
|
+
"""
|
|
384
|
+
lines = []
|
|
385
|
+
|
|
386
|
+
# Create a cage ID map for rendering
|
|
387
|
+
cage_map = {}
|
|
388
|
+
for cage_id, cage in enumerate(self.cages):
|
|
389
|
+
for r, c in cage.cells:
|
|
390
|
+
cage_map[(r, c)] = (cage_id, cage.operation, cage.target)
|
|
391
|
+
|
|
392
|
+
# Determine cell width needed (accommodate cage labels)
|
|
393
|
+
max_label_len = 0
|
|
394
|
+
for cage in self.cages:
|
|
395
|
+
op_str = cage.operation.value if cage.operation else ""
|
|
396
|
+
label_len = len(f"{cage.target}{op_str}")
|
|
397
|
+
max_label_len = max(max_label_len, label_len)
|
|
398
|
+
|
|
399
|
+
# Cell width: 1 (space) + 1 (digit/.) + max_label_len, minimum 4
|
|
400
|
+
cell_width = max(4, 2 + max_label_len)
|
|
401
|
+
|
|
402
|
+
# Header - center column numbers within each cell width
|
|
403
|
+
# Row format is "N |..." so header should be " |..." to align pipes
|
|
404
|
+
header = " |" # 2 spaces + pipe to match row format "N |"
|
|
405
|
+
for i in range(self.size):
|
|
406
|
+
col_num = str(i + 1)
|
|
407
|
+
# Center the column number in the cell width
|
|
408
|
+
padding_left = (cell_width - len(col_num)) // 2
|
|
409
|
+
padding_right = cell_width - len(col_num) - padding_left
|
|
410
|
+
cell_header = " " * padding_left + col_num + " " * padding_right
|
|
411
|
+
header += cell_header + "|"
|
|
412
|
+
lines.append(header)
|
|
413
|
+
|
|
414
|
+
lines.append(" +" + ("-" * cell_width + "+") * self.size)
|
|
415
|
+
|
|
416
|
+
for row in range(self.size):
|
|
417
|
+
line = f"{row + 1} |"
|
|
418
|
+
for col in range(self.size):
|
|
419
|
+
cell = self.grid[row][col]
|
|
420
|
+
|
|
421
|
+
# Start with the cell value
|
|
422
|
+
if cell != 0:
|
|
423
|
+
cell_content = str(cell)
|
|
424
|
+
else:
|
|
425
|
+
cell_content = "."
|
|
426
|
+
# Show cage info in first cell of cage (only if cell is empty)
|
|
427
|
+
cage_id, operation, target = cage_map.get((row, col), (None, None, None))
|
|
428
|
+
if cage_id is not None:
|
|
429
|
+
# Check if this is the first cell of the cage
|
|
430
|
+
cage_cells = self.cages[cage_id].cells
|
|
431
|
+
if (row, col) == min(cage_cells):
|
|
432
|
+
op_str = operation.value if operation else ""
|
|
433
|
+
cage_label = f"{target}{op_str}"
|
|
434
|
+
cell_content = f"{cell_content}{cage_label}"
|
|
435
|
+
|
|
436
|
+
# Pad to fixed width (cell_width includes the border spacing)
|
|
437
|
+
padded_content = f" {cell_content}".ljust(cell_width)
|
|
438
|
+
line += f"{padded_content}|"
|
|
439
|
+
lines.append(line)
|
|
440
|
+
lines.append(" +" + ("-" * cell_width + "+") * self.size)
|
|
441
|
+
|
|
442
|
+
# Show cage legend
|
|
443
|
+
lines.append("\nCages:")
|
|
444
|
+
for _cage_id, cage in enumerate(self.cages):
|
|
445
|
+
op_str = cage.operation.value if cage.operation else ""
|
|
446
|
+
cells_str = ", ".join(f"({r + 1},{c + 1})" for r, c in sorted(cage.cells))
|
|
447
|
+
lines.append(f" {cage.target}{op_str}: {cells_str}")
|
|
448
|
+
|
|
449
|
+
return "\n".join(lines)
|
|
450
|
+
|
|
451
|
+
def get_rules(self) -> str:
|
|
452
|
+
"""Get the rules description for KenKen.
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
Multi-line string describing the puzzle rules
|
|
456
|
+
"""
|
|
457
|
+
return f"""KENKEN RULES:
|
|
458
|
+
- Fill {self.size}x{self.size} grid with 1-{self.size}
|
|
459
|
+
- No repeats in rows or columns
|
|
460
|
+
- Satisfy cage arithmetic constraints
|
|
461
|
+
- Operations: + - * /"""
|
|
462
|
+
|
|
463
|
+
def get_commands(self) -> str:
|
|
464
|
+
"""Get the available commands for KenKen.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Multi-line string describing available commands
|
|
468
|
+
"""
|
|
469
|
+
return """KENKEN COMMANDS:
|
|
470
|
+
place <row> <col> <num> - Place a number (e.g., 'place 1 2 4')
|
|
471
|
+
clear <row> <col> - Clear a cell
|
|
472
|
+
show - Display the current grid
|
|
473
|
+
hint - Get a hint for the next move
|
|
474
|
+
check - Check your progress
|
|
475
|
+
solve - Show the solution (ends game)
|
|
476
|
+
menu - Return to game selection
|
|
477
|
+
quit - Exit the server"""
|
|
478
|
+
|
|
479
|
+
def get_stats(self) -> str:
|
|
480
|
+
"""Get current game statistics.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
String with game stats
|
|
484
|
+
"""
|
|
485
|
+
empty = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
|
|
486
|
+
return f"Moves made: {self.moves_made} | Empty cells: {empty} | Grid size: {self.size}x{self.size} | Seed: {self.seed}"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""KenKen game models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
from .enums import ArithmeticOperation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Cage(BaseModel):
|
|
9
|
+
"""A cage in KenKen game."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(frozen=True) # Cages don't change once created
|
|
12
|
+
|
|
13
|
+
cells: list[tuple[int, int]] = Field(min_length=1, description="List of cell coordinates (0-indexed)")
|
|
14
|
+
operation: ArithmeticOperation | None = Field(description="Arithmetic operation for the cage")
|
|
15
|
+
target: int = Field(description="Target value for the cage")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Configuration for Killer Sudoku game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KillerSudokuConfig(BaseModel):
|
|
9
|
+
"""Configuration for Killer Sudoku game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
num_cages: int = Field(ge=15, le=35, description="Number of cages")
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "KillerSudokuConfig":
|
|
16
|
+
"""Create config from difficulty level."""
|
|
17
|
+
config_map = {
|
|
18
|
+
DifficultyLevel.EASY: {"num_cages": 20},
|
|
19
|
+
DifficultyLevel.MEDIUM: {"num_cages": 25},
|
|
20
|
+
DifficultyLevel.HARD: {"num_cages": 30},
|
|
21
|
+
}
|
|
22
|
+
params = config_map[difficulty]
|
|
23
|
+
return cls(difficulty=difficulty, **params)
|