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,340 @@
|
|
|
1
|
+
"""Knapsack optimization puzzle game implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ...models import DifficultyLevel, DifficultyProfile, MoveResult
|
|
6
|
+
from .._base import PuzzleGame
|
|
7
|
+
from .config import KnapsackConfig
|
|
8
|
+
from .enums import KnapsackAction
|
|
9
|
+
from .models import Item
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KnapsackGame(PuzzleGame):
|
|
13
|
+
"""Knapsack optimization puzzle game.
|
|
14
|
+
|
|
15
|
+
Classic optimization problem: select items to maximize value
|
|
16
|
+
while staying within weight capacity.
|
|
17
|
+
Demonstrates objective optimization (not just constraint satisfaction).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
21
|
+
"""Initialize a new Knapsack game.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
difficulty: Game difficulty level (easy/medium/hard)
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
27
|
+
|
|
28
|
+
# Use pydantic config based on difficulty
|
|
29
|
+
self.config = KnapsackConfig.from_difficulty(self.difficulty)
|
|
30
|
+
self.max_weight = self.config.max_weight
|
|
31
|
+
|
|
32
|
+
# Item properties - now using Item pydantic model
|
|
33
|
+
self.items: list[Item] = []
|
|
34
|
+
self.capacity: int = 0
|
|
35
|
+
|
|
36
|
+
# Player's selection (True = selected, False = not selected)
|
|
37
|
+
self.selection: list[bool] = []
|
|
38
|
+
|
|
39
|
+
# Solution tracking
|
|
40
|
+
self.optimal_value = 0
|
|
41
|
+
self.optimal_selection: list[bool] = []
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
"""The display name of this puzzle type."""
|
|
46
|
+
return "Knapsack"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def description(self) -> str:
|
|
50
|
+
"""A one-line description of this puzzle type."""
|
|
51
|
+
return "Optimize item selection to maximize value within weight limit"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def constraint_types(self) -> list[str]:
|
|
55
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
56
|
+
return ["optimization", "capacity_constraint", "binary_choice", "objective_maximization"]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def business_analogies(self) -> list[str]:
|
|
60
|
+
"""Business problems this puzzle models."""
|
|
61
|
+
return ["portfolio_selection", "feature_prioritization", "budget_allocation", "resource_optimization"]
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
65
|
+
"""Complexity profile of this puzzle."""
|
|
66
|
+
return {"reasoning_type": "optimization", "search_space": "exponential", "constraint_density": "sparse"}
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def optimal_steps(self) -> int | None:
|
|
70
|
+
"""Minimum steps = items to select."""
|
|
71
|
+
if not hasattr(self, "optimal_selection") or not self.optimal_selection:
|
|
72
|
+
return None
|
|
73
|
+
return sum(self.optimal_selection)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
77
|
+
"""Difficulty characteristics for Knapsack."""
|
|
78
|
+
|
|
79
|
+
logic_depth = {
|
|
80
|
+
DifficultyLevel.EASY.value: 2,
|
|
81
|
+
DifficultyLevel.MEDIUM.value: 3,
|
|
82
|
+
DifficultyLevel.HARD.value: 4,
|
|
83
|
+
}.get(self.difficulty.value, 3)
|
|
84
|
+
return DifficultyProfile(
|
|
85
|
+
logic_depth=logic_depth,
|
|
86
|
+
branching_factor=2.0, # Select or not
|
|
87
|
+
state_observability=1.0,
|
|
88
|
+
constraint_density=0.3,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def generate_puzzle(self) -> None:
|
|
92
|
+
"""Generate a new Knapsack puzzle."""
|
|
93
|
+
# Generate random items with weights and values
|
|
94
|
+
item_names = [
|
|
95
|
+
"Gold Bar",
|
|
96
|
+
"Diamond",
|
|
97
|
+
"Ruby",
|
|
98
|
+
"Emerald",
|
|
99
|
+
"Sapphire",
|
|
100
|
+
"Platinum",
|
|
101
|
+
"Silver",
|
|
102
|
+
"Jade",
|
|
103
|
+
"Opal",
|
|
104
|
+
"Pearl",
|
|
105
|
+
"Topaz",
|
|
106
|
+
"Amethyst",
|
|
107
|
+
"Garnet",
|
|
108
|
+
"Quartz",
|
|
109
|
+
"Obsidian",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
self.items = []
|
|
113
|
+
total_weight = 0
|
|
114
|
+
|
|
115
|
+
num_items = self.config.num_items
|
|
116
|
+
|
|
117
|
+
for i in range(num_items):
|
|
118
|
+
name = item_names[i] if i < len(item_names) else f"Item {i + 1}"
|
|
119
|
+
weight = self._rng.randint(1, 10)
|
|
120
|
+
# Value roughly correlates with weight but with variance
|
|
121
|
+
value = self._rng.randint(weight * 5, weight * 15)
|
|
122
|
+
|
|
123
|
+
self.items.append(Item(name=name, weight=weight, value=value))
|
|
124
|
+
total_weight += weight
|
|
125
|
+
|
|
126
|
+
# Set capacity as a fraction of total weight
|
|
127
|
+
capacity_factor_map = {
|
|
128
|
+
DifficultyLevel.EASY: 0.6,
|
|
129
|
+
DifficultyLevel.MEDIUM: 0.5,
|
|
130
|
+
DifficultyLevel.HARD: 0.4,
|
|
131
|
+
}
|
|
132
|
+
capacity_factor = capacity_factor_map[self.difficulty]
|
|
133
|
+
self.capacity = int(total_weight * capacity_factor)
|
|
134
|
+
if self.capacity < 5:
|
|
135
|
+
self.capacity = 5 # Minimum capacity
|
|
136
|
+
|
|
137
|
+
# Initialize empty selection
|
|
138
|
+
self.selection = [False] * num_items
|
|
139
|
+
|
|
140
|
+
# Calculate optimal solution using dynamic programming
|
|
141
|
+
self._solve_optimal()
|
|
142
|
+
|
|
143
|
+
self.moves_made = 0
|
|
144
|
+
self.game_started = True
|
|
145
|
+
|
|
146
|
+
def _solve_optimal(self) -> None:
|
|
147
|
+
"""Solve the knapsack problem optimally using dynamic programming."""
|
|
148
|
+
n = len(self.items)
|
|
149
|
+
capacity = self.capacity
|
|
150
|
+
|
|
151
|
+
# DP table: dp[i][w] = max value using first i items with capacity w
|
|
152
|
+
dp = [[0 for _ in range(capacity + 1)] for _ in range(n + 1)]
|
|
153
|
+
|
|
154
|
+
# Fill the DP table
|
|
155
|
+
for i in range(1, n + 1):
|
|
156
|
+
item = self.items[i - 1]
|
|
157
|
+
weight = item.weight
|
|
158
|
+
value = item.value
|
|
159
|
+
|
|
160
|
+
for w in range(capacity + 1):
|
|
161
|
+
# Don't take item i
|
|
162
|
+
dp[i][w] = dp[i - 1][w]
|
|
163
|
+
|
|
164
|
+
# Take item i if it fits
|
|
165
|
+
if weight <= w:
|
|
166
|
+
dp[i][w] = max(dp[i][w], dp[i - 1][w - weight] + value)
|
|
167
|
+
|
|
168
|
+
# Backtrack to find which items to select
|
|
169
|
+
self.optimal_value = dp[n][capacity]
|
|
170
|
+
self.optimal_selection = [False] * n
|
|
171
|
+
|
|
172
|
+
w = capacity
|
|
173
|
+
for i in range(n, 0, -1):
|
|
174
|
+
if dp[i][w] != dp[i - 1][w]:
|
|
175
|
+
self.optimal_selection[i - 1] = True
|
|
176
|
+
w -= self.items[i - 1].weight
|
|
177
|
+
|
|
178
|
+
async def validate_move(self, action: str, item_index: int) -> MoveResult:
|
|
179
|
+
"""Toggle item selection.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
action: 'select' or 'deselect'
|
|
183
|
+
item_index: Item number (1-indexed, user-facing)
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
MoveResult with success status and message
|
|
187
|
+
"""
|
|
188
|
+
# Convert to 0-indexed
|
|
189
|
+
item_index -= 1
|
|
190
|
+
|
|
191
|
+
# Validate item index
|
|
192
|
+
if not (0 <= item_index < len(self.items)):
|
|
193
|
+
return MoveResult(success=False, message=f"Invalid item number. Use 1-{len(self.items)}.")
|
|
194
|
+
|
|
195
|
+
# Parse action using KnapsackAction enum
|
|
196
|
+
try:
|
|
197
|
+
action_enum = KnapsackAction(action.lower())
|
|
198
|
+
except ValueError:
|
|
199
|
+
return MoveResult(success=False, message="Invalid action. Use 'select' or 'deselect'.")
|
|
200
|
+
|
|
201
|
+
if action_enum == KnapsackAction.SELECT:
|
|
202
|
+
if self.selection[item_index]:
|
|
203
|
+
return MoveResult(success=False, message="Item is already selected.")
|
|
204
|
+
|
|
205
|
+
# Check if adding this item exceeds capacity
|
|
206
|
+
current_weight = self._get_current_weight()
|
|
207
|
+
item_weight = self.items[item_index].weight
|
|
208
|
+
|
|
209
|
+
if current_weight + item_weight > self.capacity:
|
|
210
|
+
return MoveResult(
|
|
211
|
+
success=False,
|
|
212
|
+
message=f"Cannot select - would exceed capacity! (Current: {current_weight}, Item: {item_weight}, Capacity: {self.capacity})",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self.selection[item_index] = True
|
|
216
|
+
self.moves_made += 1
|
|
217
|
+
item_name = self.items[item_index].name
|
|
218
|
+
return MoveResult(
|
|
219
|
+
success=True,
|
|
220
|
+
message=f"Selected {item_name} (weight: {item_weight}, value: ${self.items[item_index].value})",
|
|
221
|
+
state_changed=True,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
elif action_enum == KnapsackAction.DESELECT:
|
|
225
|
+
if not self.selection[item_index]:
|
|
226
|
+
return MoveResult(success=False, message="Item is not currently selected.")
|
|
227
|
+
|
|
228
|
+
self.selection[item_index] = False
|
|
229
|
+
self.moves_made += 1
|
|
230
|
+
item_name = self.items[item_index].name
|
|
231
|
+
return MoveResult(success=True, message=f"Deselected {item_name}", state_changed=True)
|
|
232
|
+
|
|
233
|
+
# Should never reach here due to enum validation above
|
|
234
|
+
return MoveResult(success=False, message="Invalid action. Use 'select' or 'deselect'.")
|
|
235
|
+
|
|
236
|
+
def _get_current_weight(self) -> int:
|
|
237
|
+
"""Calculate total weight of currently selected items."""
|
|
238
|
+
return sum(self.items[i].weight for i in range(len(self.items)) if self.selection[i])
|
|
239
|
+
|
|
240
|
+
def _get_current_value(self) -> int:
|
|
241
|
+
"""Calculate total value of currently selected items."""
|
|
242
|
+
return sum(self.items[i].value for i in range(len(self.items)) if self.selection[i])
|
|
243
|
+
|
|
244
|
+
def is_complete(self) -> bool:
|
|
245
|
+
"""Check if the solution is optimal.
|
|
246
|
+
|
|
247
|
+
For optimization problems, we consider it complete if the player
|
|
248
|
+
has achieved the optimal value.
|
|
249
|
+
"""
|
|
250
|
+
return self._get_current_value() == self.optimal_value
|
|
251
|
+
|
|
252
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
253
|
+
"""Get a hint for the next move.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Tuple of (hint_data, hint_message) or None
|
|
257
|
+
"""
|
|
258
|
+
# Suggest selecting an item that's in the optimal solution but not selected
|
|
259
|
+
for i in range(len(self.items)):
|
|
260
|
+
if self.optimal_selection[i] and not self.selection[i]:
|
|
261
|
+
hint_data = ("select", i + 1)
|
|
262
|
+
hint_message = f"Try selecting item {i + 1} ({self.items[i].name})"
|
|
263
|
+
return hint_data, hint_message
|
|
264
|
+
|
|
265
|
+
# Suggest deselecting an item that's not in the optimal solution but is selected
|
|
266
|
+
for i in range(len(self.items)):
|
|
267
|
+
if not self.optimal_selection[i] and self.selection[i]:
|
|
268
|
+
hint_data = ("deselect", i + 1)
|
|
269
|
+
hint_message = f"Try deselecting item {i + 1} ({self.items[i].name})"
|
|
270
|
+
return hint_data, hint_message
|
|
271
|
+
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
def render_grid(self) -> str:
|
|
275
|
+
"""Render the current game state as ASCII art.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
String representation of the puzzle
|
|
279
|
+
"""
|
|
280
|
+
lines = []
|
|
281
|
+
|
|
282
|
+
lines.append(f"Knapsack Capacity: {self.capacity} kg")
|
|
283
|
+
lines.append(f"Current Weight: {self._get_current_weight()} kg")
|
|
284
|
+
lines.append(f"Current Value: ${self._get_current_value()}")
|
|
285
|
+
lines.append(f"Optimal Value: ${self.optimal_value}")
|
|
286
|
+
lines.append("")
|
|
287
|
+
|
|
288
|
+
# Items table
|
|
289
|
+
lines.append(" # | Item | Weight | Value | Selected")
|
|
290
|
+
lines.append(" --+---------------+--------+--------+---------")
|
|
291
|
+
|
|
292
|
+
for i, item in enumerate(self.items):
|
|
293
|
+
selected = "✓" if self.selection[i] else " "
|
|
294
|
+
lines.append(f" {i + 1:2d} | {item.name:<13s} | {item.weight:4d}kg | ${item.value:5d} | {selected}")
|
|
295
|
+
|
|
296
|
+
lines.append("")
|
|
297
|
+
lines.append(f"Space Remaining: {self.capacity - self._get_current_weight()} kg")
|
|
298
|
+
|
|
299
|
+
return "\n".join(lines)
|
|
300
|
+
|
|
301
|
+
def get_rules(self) -> str:
|
|
302
|
+
"""Get the rules description for Knapsack.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Multi-line string describing the puzzle rules
|
|
306
|
+
"""
|
|
307
|
+
return f"""KNAPSACK RULES:
|
|
308
|
+
- Select items to maximize total value
|
|
309
|
+
- Cannot exceed capacity of {self.capacity}kg
|
|
310
|
+
- Each item can be selected at most once
|
|
311
|
+
- Goal: Achieve optimal value of ${self.optimal_value}
|
|
312
|
+
- This is an OPTIMIZATION problem - find the best solution!"""
|
|
313
|
+
|
|
314
|
+
def get_commands(self) -> str:
|
|
315
|
+
"""Get the available commands for Knapsack.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Multi-line string describing available commands
|
|
319
|
+
"""
|
|
320
|
+
return """KNAPSACK COMMANDS:
|
|
321
|
+
select <number> - Select an item (e.g., 'select 3')
|
|
322
|
+
deselect <number> - Deselect an item (e.g., 'deselect 2')
|
|
323
|
+
show - Display current selection
|
|
324
|
+
hint - Get a hint for optimization
|
|
325
|
+
check - Check if you've found the optimal solution
|
|
326
|
+
solve - Show the optimal solution (ends game)
|
|
327
|
+
menu - Return to game selection
|
|
328
|
+
quit - Exit the server"""
|
|
329
|
+
|
|
330
|
+
def get_stats(self) -> str:
|
|
331
|
+
"""Get current game statistics.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
String with game stats
|
|
335
|
+
"""
|
|
336
|
+
current_value = self._get_current_value()
|
|
337
|
+
current_weight = self._get_current_weight()
|
|
338
|
+
optimality = (current_value / self.optimal_value * 100) if self.optimal_value > 0 else 0
|
|
339
|
+
|
|
340
|
+
return f"Moves: {self.moves_made} | Value: ${current_value}/${self.optimal_value} ({optimality:.0f}%) | Weight: {current_weight}/{self.capacity}kg | Seed: {self.seed}"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Knapsack game models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Item(BaseModel):
|
|
7
|
+
"""An item in the Knapsack game."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(frozen=True) # Items don't change once created
|
|
10
|
+
|
|
11
|
+
name: str = Field(min_length=1, description="Item name")
|
|
12
|
+
weight: int = Field(gt=0, description="Item weight")
|
|
13
|
+
value: int = Field(gt=0, description="Item value")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Configuration for Lights Out game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LightsOutConfig(BaseModel):
|
|
9
|
+
"""Configuration for Lights Out game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
size: int = Field(ge=3, le=10, description="Grid size (NxN)")
|
|
13
|
+
num_presses: int = Field(ge=1, description="Number of initial presses to create puzzle")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "LightsOutConfig":
|
|
17
|
+
"""Create config from difficulty level."""
|
|
18
|
+
config_map = {
|
|
19
|
+
DifficultyLevel.EASY: {"size": 5, "num_presses": 3},
|
|
20
|
+
DifficultyLevel.MEDIUM: {"size": 6, "num_presses": 5},
|
|
21
|
+
DifficultyLevel.HARD: {"size": 7, "num_presses": 7},
|
|
22
|
+
}
|
|
23
|
+
params = config_map[difficulty]
|
|
24
|
+
return cls(difficulty=difficulty, **params)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Lights Out 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 LightsOutConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LightsOutGame(PuzzleGame):
|
|
11
|
+
"""Lights Out puzzle game.
|
|
12
|
+
|
|
13
|
+
Click lights to toggle them and their neighbors.
|
|
14
|
+
Goal: Turn all lights off.
|
|
15
|
+
Perfect demonstration of boolean XOR constraints.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
19
|
+
"""Initialize a new Lights Out game.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
difficulty: Game difficulty level (easy=5x5, medium=6x6, hard=7x7)
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
25
|
+
|
|
26
|
+
# Use pydantic config based on difficulty
|
|
27
|
+
self.config = LightsOutConfig.from_difficulty(self.difficulty)
|
|
28
|
+
self.size = self.config.size
|
|
29
|
+
|
|
30
|
+
# Grid: 0 = off, 1 = on
|
|
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
|
+
# Track which cells need to be pressed (solution)
|
|
36
|
+
self.presses = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
"""The display name of this puzzle type."""
|
|
41
|
+
return "Lights Out"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def description(self) -> str:
|
|
45
|
+
"""A one-line description of this puzzle type."""
|
|
46
|
+
return "Toggle lights to turn all off - XOR constraint puzzle"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def constraint_types(self) -> list[str]:
|
|
50
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
51
|
+
return ["boolean_sat", "xor_constraints", "parity", "linear_algebra"]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def business_analogies(self) -> list[str]:
|
|
55
|
+
"""Business problems this puzzle models."""
|
|
56
|
+
return ["toggle_systems", "parity_checking", "state_synchronization"]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
60
|
+
"""Complexity profile of this puzzle."""
|
|
61
|
+
return {"reasoning_type": "deductive", "search_space": "exponential", "constraint_density": "dense"}
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def optimal_steps(self) -> int | None:
|
|
65
|
+
"""Minimum steps = presses needed."""
|
|
66
|
+
if not hasattr(self, "presses") or not self.presses:
|
|
67
|
+
return None
|
|
68
|
+
return sum(1 for r in range(self.size) for c in range(self.size) if self.presses[r][c] == 1)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
72
|
+
"""Difficulty characteristics for Lights Out."""
|
|
73
|
+
from ...models import DifficultyLevel
|
|
74
|
+
|
|
75
|
+
logic_depth = {
|
|
76
|
+
DifficultyLevel.EASY.value: 2,
|
|
77
|
+
DifficultyLevel.MEDIUM.value: 3,
|
|
78
|
+
DifficultyLevel.HARD.value: 4,
|
|
79
|
+
}.get(self.difficulty.value, 3)
|
|
80
|
+
return DifficultyProfile(
|
|
81
|
+
logic_depth=logic_depth,
|
|
82
|
+
branching_factor=2.0, # Press or not
|
|
83
|
+
state_observability=1.0,
|
|
84
|
+
constraint_density=0.7,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _toggle_cell(self, row: int, col: int, grid: list[list[int]]) -> None:
|
|
88
|
+
"""Toggle a cell and its neighbors.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
row: Row index
|
|
92
|
+
col: Column index
|
|
93
|
+
grid: Grid to modify
|
|
94
|
+
"""
|
|
95
|
+
# Toggle the cell itself
|
|
96
|
+
grid[row][col] = 1 - grid[row][col]
|
|
97
|
+
|
|
98
|
+
# Toggle neighbors (up, down, left, right)
|
|
99
|
+
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
|
|
100
|
+
for dr, dc in directions:
|
|
101
|
+
new_row, new_col = row + dr, col + dc
|
|
102
|
+
if 0 <= new_row < self.size and 0 <= new_col < self.size:
|
|
103
|
+
grid[new_row][new_col] = 1 - grid[new_row][new_col]
|
|
104
|
+
|
|
105
|
+
async def generate_puzzle(self) -> None:
|
|
106
|
+
"""Generate a new Lights Out puzzle."""
|
|
107
|
+
# Start with all lights off
|
|
108
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
109
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
110
|
+
|
|
111
|
+
# Generate a random solution pattern (which cells to press)
|
|
112
|
+
num_presses = self.config.num_presses
|
|
113
|
+
|
|
114
|
+
# Randomly select cells to press
|
|
115
|
+
pressed_cells: set[tuple[int, int]] = set()
|
|
116
|
+
attempts = 0
|
|
117
|
+
while len(pressed_cells) < num_presses and attempts < 100:
|
|
118
|
+
row = self._rng.randint(0, self.size - 1)
|
|
119
|
+
col = self._rng.randint(0, self.size - 1)
|
|
120
|
+
pressed_cells.add((row, col))
|
|
121
|
+
attempts += 1
|
|
122
|
+
|
|
123
|
+
# Apply these presses to create the puzzle
|
|
124
|
+
for row, col in pressed_cells:
|
|
125
|
+
self.presses[row][col] = 1
|
|
126
|
+
self._toggle_cell(row, col, self.grid)
|
|
127
|
+
|
|
128
|
+
# Store initial state
|
|
129
|
+
self.initial_grid = [row[:] for row in self.grid]
|
|
130
|
+
self.moves_made = 0
|
|
131
|
+
self.game_started = True
|
|
132
|
+
|
|
133
|
+
async def validate_move(self, row: int, col: int) -> MoveResult:
|
|
134
|
+
"""Toggle a cell (press it).
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
row: Row index (1-indexed, user-facing)
|
|
138
|
+
col: Column index (1-indexed, user-facing)
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
MoveResult with success status and message
|
|
142
|
+
"""
|
|
143
|
+
# Convert to 0-indexed
|
|
144
|
+
row -= 1
|
|
145
|
+
col -= 1
|
|
146
|
+
|
|
147
|
+
# Validate coordinates
|
|
148
|
+
if not (0 <= row < self.size and 0 <= col < self.size):
|
|
149
|
+
return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
|
|
150
|
+
|
|
151
|
+
# Toggle the cell and neighbors
|
|
152
|
+
self._toggle_cell(row, col, self.grid)
|
|
153
|
+
|
|
154
|
+
# Update solution tracking - XOR the press state
|
|
155
|
+
# (pressing a cell twice cancels out)
|
|
156
|
+
self.presses[row][col] = 1 - self.presses[row][col]
|
|
157
|
+
|
|
158
|
+
self.moves_made += 1
|
|
159
|
+
|
|
160
|
+
return MoveResult(success=True, message=f"Toggled light at ({row + 1}, {col + 1})", state_changed=True)
|
|
161
|
+
|
|
162
|
+
def is_complete(self) -> bool:
|
|
163
|
+
"""Check if the puzzle is complete (all lights off)."""
|
|
164
|
+
for row in range(self.size):
|
|
165
|
+
for col in range(self.size):
|
|
166
|
+
if self.grid[row][col] == 1:
|
|
167
|
+
return False
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
171
|
+
"""Get a hint for the next move.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
175
|
+
"""
|
|
176
|
+
# Find a cell in the solution that should be pressed
|
|
177
|
+
for row in range(self.size):
|
|
178
|
+
for col in range(self.size):
|
|
179
|
+
if self.presses[row][col] == 1:
|
|
180
|
+
hint_data = (row + 1, col + 1)
|
|
181
|
+
hint_message = f"Try pressing the light at row {row + 1}, column {col + 1}"
|
|
182
|
+
return hint_data, hint_message
|
|
183
|
+
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def render_grid(self) -> str:
|
|
187
|
+
"""Render the current puzzle state as ASCII art.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
String representation of the puzzle grid
|
|
191
|
+
"""
|
|
192
|
+
lines = []
|
|
193
|
+
|
|
194
|
+
# Header
|
|
195
|
+
header = " |"
|
|
196
|
+
for i in range(self.size):
|
|
197
|
+
header += f"{i + 1}|"
|
|
198
|
+
lines.append(header)
|
|
199
|
+
lines.append(" +" + "-+" * self.size)
|
|
200
|
+
|
|
201
|
+
# Grid rows
|
|
202
|
+
for row in range(self.size):
|
|
203
|
+
line = f"{row + 1} |"
|
|
204
|
+
for col in range(self.size):
|
|
205
|
+
cell = self.grid[row][col]
|
|
206
|
+
# ● = on, ○ = off
|
|
207
|
+
symbol = "●" if cell == 1 else "○"
|
|
208
|
+
line += f"{symbol}|"
|
|
209
|
+
lines.append(line)
|
|
210
|
+
lines.append(" +" + "-+" * self.size)
|
|
211
|
+
|
|
212
|
+
return "\n".join(lines)
|
|
213
|
+
|
|
214
|
+
def get_rules(self) -> str:
|
|
215
|
+
"""Get the rules description for Lights Out.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Multi-line string describing the puzzle rules
|
|
219
|
+
"""
|
|
220
|
+
return f"""LIGHTS OUT RULES:
|
|
221
|
+
- Click a light to toggle it and its neighbors
|
|
222
|
+
- Neighbors = up, down, left, right (not diagonal)
|
|
223
|
+
- Goal: Turn ALL lights off (○)
|
|
224
|
+
- ● = light ON, ○ = light OFF
|
|
225
|
+
- Grid size: {self.size}×{self.size}"""
|
|
226
|
+
|
|
227
|
+
def get_commands(self) -> str:
|
|
228
|
+
"""Get the available commands for Lights Out.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Multi-line string describing available commands
|
|
232
|
+
"""
|
|
233
|
+
return """LIGHTS OUT COMMANDS:
|
|
234
|
+
press <row> <col> - Press a light (e.g., 'press 2 3')
|
|
235
|
+
show - Display the current grid
|
|
236
|
+
hint - Get a hint for the next move
|
|
237
|
+
check - Check if all lights are off
|
|
238
|
+
reset - Reset to initial state
|
|
239
|
+
menu - Return to game selection
|
|
240
|
+
quit - Exit the server"""
|
|
241
|
+
|
|
242
|
+
def get_stats(self) -> str:
|
|
243
|
+
"""Get current game statistics.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
String with game stats
|
|
247
|
+
"""
|
|
248
|
+
lights_on = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 1)
|
|
249
|
+
return f"Moves made: {self.moves_made} | Lights ON: {lights_on} | Grid size: {self.size}×{self.size} | Seed: {self.seed}"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Configuration for Logic Grid game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LogicGridConfig(BaseModel):
|
|
9
|
+
"""Configuration for Logic Grid game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
num_people: int = Field(ge=3, le=5, description="Number of people")
|
|
13
|
+
num_attributes: int = Field(ge=3, le=5, description="Number of attributes per category")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "LogicGridConfig":
|
|
17
|
+
"""Create config from difficulty level."""
|
|
18
|
+
config_map = {
|
|
19
|
+
DifficultyLevel.EASY: {"num_people": 3, "num_attributes": 3},
|
|
20
|
+
DifficultyLevel.MEDIUM: {"num_people": 4, "num_attributes": 4},
|
|
21
|
+
DifficultyLevel.HARD: {"num_people": 5, "num_attributes": 5},
|
|
22
|
+
}
|
|
23
|
+
params = config_map[difficulty]
|
|
24
|
+
return cls(difficulty=difficulty, **params)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Logic Grid puzzle constants and static data."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
# Logic Grid categories
|
|
6
|
+
PEOPLE: Final[list[str]] = ["Alice", "Bob", "Carol", "Dave", "Eve"]
|
|
7
|
+
COLORS: Final[list[str]] = ["Red", "Blue", "Green", "Yellow", "Purple"]
|
|
8
|
+
PETS: Final[list[str]] = ["Cat", "Dog", "Bird", "Fish", "Rabbit"]
|
|
9
|
+
DRINKS: Final[list[str]] = ["Coffee", "Tea", "Juice", "Water", "Milk"]
|
|
10
|
+
|
|
11
|
+
# Category names (for iteration)
|
|
12
|
+
CATEGORIES: Final[list[str]] = ["person", "color", "pet", "drink"]
|