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.
Files changed (112) hide show
  1. chuk_puzzles_gym/__init__.py +19 -0
  2. chuk_puzzles_gym/constants.py +9 -0
  3. chuk_puzzles_gym/eval.py +763 -0
  4. chuk_puzzles_gym/export/__init__.py +20 -0
  5. chuk_puzzles_gym/export/dataset.py +376 -0
  6. chuk_puzzles_gym/games/__init__.py +94 -0
  7. chuk_puzzles_gym/games/_base/__init__.py +6 -0
  8. chuk_puzzles_gym/games/_base/commands.py +91 -0
  9. chuk_puzzles_gym/games/_base/game.py +337 -0
  10. chuk_puzzles_gym/games/binary/__init__.py +6 -0
  11. chuk_puzzles_gym/games/binary/config.py +23 -0
  12. chuk_puzzles_gym/games/binary/game.py +434 -0
  13. chuk_puzzles_gym/games/bridges/__init__.py +6 -0
  14. chuk_puzzles_gym/games/bridges/config.py +24 -0
  15. chuk_puzzles_gym/games/bridges/game.py +489 -0
  16. chuk_puzzles_gym/games/einstein/__init__.py +6 -0
  17. chuk_puzzles_gym/games/einstein/config.py +23 -0
  18. chuk_puzzles_gym/games/einstein/constants.py +13 -0
  19. chuk_puzzles_gym/games/einstein/game.py +366 -0
  20. chuk_puzzles_gym/games/einstein/models.py +35 -0
  21. chuk_puzzles_gym/games/fillomino/__init__.py +6 -0
  22. chuk_puzzles_gym/games/fillomino/config.py +24 -0
  23. chuk_puzzles_gym/games/fillomino/game.py +516 -0
  24. chuk_puzzles_gym/games/futoshiki/__init__.py +6 -0
  25. chuk_puzzles_gym/games/futoshiki/config.py +23 -0
  26. chuk_puzzles_gym/games/futoshiki/game.py +391 -0
  27. chuk_puzzles_gym/games/hidato/__init__.py +6 -0
  28. chuk_puzzles_gym/games/hidato/config.py +24 -0
  29. chuk_puzzles_gym/games/hidato/game.py +403 -0
  30. chuk_puzzles_gym/games/hitori/__init__.py +6 -0
  31. chuk_puzzles_gym/games/hitori/config.py +23 -0
  32. chuk_puzzles_gym/games/hitori/game.py +451 -0
  33. chuk_puzzles_gym/games/kakuro/__init__.py +6 -0
  34. chuk_puzzles_gym/games/kakuro/config.py +24 -0
  35. chuk_puzzles_gym/games/kakuro/game.py +399 -0
  36. chuk_puzzles_gym/games/kenken/__init__.py +6 -0
  37. chuk_puzzles_gym/games/kenken/config.py +24 -0
  38. chuk_puzzles_gym/games/kenken/enums.py +13 -0
  39. chuk_puzzles_gym/games/kenken/game.py +486 -0
  40. chuk_puzzles_gym/games/kenken/models.py +15 -0
  41. chuk_puzzles_gym/games/killer_sudoku/__init__.py +6 -0
  42. chuk_puzzles_gym/games/killer_sudoku/config.py +23 -0
  43. chuk_puzzles_gym/games/killer_sudoku/game.py +502 -0
  44. chuk_puzzles_gym/games/killer_sudoku/models.py +15 -0
  45. chuk_puzzles_gym/games/knapsack/__init__.py +6 -0
  46. chuk_puzzles_gym/games/knapsack/config.py +24 -0
  47. chuk_puzzles_gym/games/knapsack/enums.py +10 -0
  48. chuk_puzzles_gym/games/knapsack/game.py +340 -0
  49. chuk_puzzles_gym/games/knapsack/models.py +13 -0
  50. chuk_puzzles_gym/games/lights_out/__init__.py +6 -0
  51. chuk_puzzles_gym/games/lights_out/config.py +24 -0
  52. chuk_puzzles_gym/games/lights_out/game.py +249 -0
  53. chuk_puzzles_gym/games/logic_grid/__init__.py +6 -0
  54. chuk_puzzles_gym/games/logic_grid/config.py +24 -0
  55. chuk_puzzles_gym/games/logic_grid/constants.py +12 -0
  56. chuk_puzzles_gym/games/logic_grid/game.py +333 -0
  57. chuk_puzzles_gym/games/logic_grid/models.py +24 -0
  58. chuk_puzzles_gym/games/mastermind/__init__.py +6 -0
  59. chuk_puzzles_gym/games/mastermind/config.py +25 -0
  60. chuk_puzzles_gym/games/mastermind/game.py +297 -0
  61. chuk_puzzles_gym/games/minesweeper/__init__.py +6 -0
  62. chuk_puzzles_gym/games/minesweeper/config.py +24 -0
  63. chuk_puzzles_gym/games/minesweeper/enums.py +12 -0
  64. chuk_puzzles_gym/games/minesweeper/game.py +432 -0
  65. chuk_puzzles_gym/games/nonogram/__init__.py +6 -0
  66. chuk_puzzles_gym/games/nonogram/config.py +23 -0
  67. chuk_puzzles_gym/games/nonogram/game.py +296 -0
  68. chuk_puzzles_gym/games/nurikabe/__init__.py +6 -0
  69. chuk_puzzles_gym/games/nurikabe/config.py +24 -0
  70. chuk_puzzles_gym/games/nurikabe/enums.py +14 -0
  71. chuk_puzzles_gym/games/nurikabe/game.py +586 -0
  72. chuk_puzzles_gym/games/scheduler/__init__.py +6 -0
  73. chuk_puzzles_gym/games/scheduler/config.py +25 -0
  74. chuk_puzzles_gym/games/scheduler/constants.py +15 -0
  75. chuk_puzzles_gym/games/scheduler/enums.py +10 -0
  76. chuk_puzzles_gym/games/scheduler/game.py +431 -0
  77. chuk_puzzles_gym/games/scheduler/models.py +14 -0
  78. chuk_puzzles_gym/games/shikaku/__init__.py +6 -0
  79. chuk_puzzles_gym/games/shikaku/config.py +24 -0
  80. chuk_puzzles_gym/games/shikaku/game.py +419 -0
  81. chuk_puzzles_gym/games/slitherlink/__init__.py +6 -0
  82. chuk_puzzles_gym/games/slitherlink/config.py +23 -0
  83. chuk_puzzles_gym/games/slitherlink/game.py +386 -0
  84. chuk_puzzles_gym/games/sokoban/__init__.py +6 -0
  85. chuk_puzzles_gym/games/sokoban/config.py +24 -0
  86. chuk_puzzles_gym/games/sokoban/game.py +671 -0
  87. chuk_puzzles_gym/games/star_battle/__init__.py +6 -0
  88. chuk_puzzles_gym/games/star_battle/config.py +24 -0
  89. chuk_puzzles_gym/games/star_battle/game.py +390 -0
  90. chuk_puzzles_gym/games/sudoku/__init__.py +7 -0
  91. chuk_puzzles_gym/games/sudoku/commands.py +96 -0
  92. chuk_puzzles_gym/games/sudoku/config.py +22 -0
  93. chuk_puzzles_gym/games/sudoku/game.py +328 -0
  94. chuk_puzzles_gym/games/tents/__init__.py +6 -0
  95. chuk_puzzles_gym/games/tents/config.py +24 -0
  96. chuk_puzzles_gym/games/tents/game.py +416 -0
  97. chuk_puzzles_gym/gym_env.py +465 -0
  98. chuk_puzzles_gym/models/__init__.py +47 -0
  99. chuk_puzzles_gym/models/base.py +30 -0
  100. chuk_puzzles_gym/models/config.py +11 -0
  101. chuk_puzzles_gym/models/enums.py +104 -0
  102. chuk_puzzles_gym/models/evaluation.py +487 -0
  103. chuk_puzzles_gym/models/games.py +12 -0
  104. chuk_puzzles_gym/server.py +1171 -0
  105. chuk_puzzles_gym/trace/__init__.py +10 -0
  106. chuk_puzzles_gym/trace/generator.py +726 -0
  107. chuk_puzzles_gym/utils/__init__.py +4 -0
  108. chuk_puzzles_gym-0.9.dist-info/METADATA +1471 -0
  109. chuk_puzzles_gym-0.9.dist-info/RECORD +112 -0
  110. chuk_puzzles_gym-0.9.dist-info/WHEEL +5 -0
  111. chuk_puzzles_gym-0.9.dist-info/entry_points.txt +4 -0
  112. 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,6 @@
1
+ """LightsOut puzzle game module."""
2
+
3
+ from .config import LightsOutConfig
4
+ from .game import LightsOutGame
5
+
6
+ __all__ = ["LightsOutGame", "LightsOutConfig"]
@@ -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,6 @@
1
+ """LogicGrid puzzle game module."""
2
+
3
+ from .config import LogicGridConfig
4
+ from .game import LogicGridGame
5
+
6
+ __all__ = ["LogicGridGame", "LogicGridConfig"]
@@ -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"]