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,296 @@
1
+ """Nonogram (Picross) 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 NonogramConfig
8
+
9
+
10
+ class NonogramGame(PuzzleGame):
11
+ """Nonogram (also known as Picross, Griddlers, or Hanjie) puzzle game.
12
+
13
+ Fill cells to reveal a picture based on number clues for each row and column.
14
+ Clues indicate consecutive filled cells in that row/column.
15
+ """
16
+
17
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
18
+ """Initialize a new Nonogram game.
19
+
20
+ Args:
21
+ difficulty: Game difficulty level (easy=5x5, medium=7x7, hard=10x10)
22
+ """
23
+ super().__init__(difficulty, seed, **kwargs)
24
+
25
+ # Use pydantic config based on difficulty
26
+ self.config = NonogramConfig.from_difficulty(self.difficulty)
27
+ self.size = self.config.size
28
+
29
+ # Grid: -1 = unknown, 0 = empty (marked X), 1 = filled (marked ■)
30
+ self.grid = [[-1 for _ in range(self.size)] for _ in range(self.size)]
31
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
32
+ self.initial_grid = [[-1 for _ in range(self.size)] for _ in range(self.size)]
33
+
34
+ # Clues: row_clues[i] = list of consecutive filled counts for row i
35
+ self.row_clues: list[list[int]] = []
36
+ self.col_clues: list[list[int]] = []
37
+
38
+ @property
39
+ def name(self) -> str:
40
+ """The display name of this puzzle type."""
41
+ return "Nonogram"
42
+
43
+ @property
44
+ def description(self) -> str:
45
+ """A one-line description of this puzzle type."""
46
+ return "Picture logic puzzle - reveal image from number clues"
47
+
48
+ @property
49
+ def constraint_types(self) -> list[str]:
50
+ """Constraint types demonstrated by this puzzle."""
51
+ return ["run_length_encoding", "linear_constraints", "cross_referencing", "pattern_completion"]
52
+
53
+ @property
54
+ def business_analogies(self) -> list[str]:
55
+ """Business problems this puzzle models."""
56
+ return ["data_reconstruction", "pattern_recognition", "image_recovery", "constraint_propagation"]
57
+
58
+ @property
59
+ def complexity_profile(self) -> dict[str, str]:
60
+ """Complexity profile of this puzzle."""
61
+ return {"reasoning_type": "deductive", "search_space": "large", "constraint_density": "dense"}
62
+
63
+ @property
64
+ def optimal_steps(self) -> int | None:
65
+ """Minimum steps = all cells to mark (filled=1 and empty=0)."""
66
+ if not hasattr(self, "solution") or not self.solution:
67
+ return None
68
+ # Count both filled (1) and empty (0) cells - all need to be marked
69
+ return sum(
70
+ 1 for r in range(len(self.solution)) for c in range(len(self.solution[0])) if self.solution[r][c] in (0, 1)
71
+ )
72
+
73
+ @property
74
+ def difficulty_profile(self) -> "DifficultyProfile":
75
+ """Difficulty characteristics for Nonogram."""
76
+
77
+ logic_depth = {
78
+ DifficultyLevel.EASY.value: 2,
79
+ DifficultyLevel.MEDIUM.value: 4,
80
+ DifficultyLevel.HARD.value: 5,
81
+ }.get(self.difficulty.value, 3)
82
+ return DifficultyProfile(
83
+ logic_depth=logic_depth,
84
+ branching_factor=2.0,
85
+ state_observability=1.0,
86
+ constraint_density=0.5,
87
+ )
88
+
89
+ def _calculate_clues(self, line: list[int]) -> list[int]:
90
+ """Calculate clues for a line (row or column).
91
+
92
+ Args:
93
+ line: List of 0s and 1s
94
+
95
+ Returns:
96
+ List of consecutive filled cell counts
97
+ """
98
+ clues = []
99
+ count = 0
100
+
101
+ for cell in line:
102
+ if cell == 1:
103
+ count += 1
104
+ elif count > 0:
105
+ clues.append(count)
106
+ count = 0
107
+
108
+ if count > 0:
109
+ clues.append(count)
110
+
111
+ return clues if clues else [0]
112
+
113
+ def _generate_pattern(self) -> None:
114
+ """Generate a random pattern for the solution."""
115
+ # Create a simple random pattern
116
+ density_map = {
117
+ DifficultyLevel.EASY: 0.4,
118
+ DifficultyLevel.MEDIUM: 0.5,
119
+ DifficultyLevel.HARD: 0.6,
120
+ }
121
+ density = density_map[self.difficulty]
122
+
123
+ for row in range(self.size):
124
+ for col in range(self.size):
125
+ self.solution[row][col] = 1 if self._rng.random() < density else 0
126
+
127
+ async def generate_puzzle(self) -> None:
128
+ """Generate a new Nonogram puzzle."""
129
+ # Generate a random pattern
130
+ self._generate_pattern()
131
+
132
+ # Calculate clues from the solution
133
+ self.row_clues = []
134
+ for row in range(self.size):
135
+ clues = self._calculate_clues(self.solution[row])
136
+ self.row_clues.append(clues)
137
+
138
+ self.col_clues = []
139
+ for col in range(self.size):
140
+ column = [self.solution[row][col] for row in range(self.size)]
141
+ clues = self._calculate_clues(column)
142
+ self.col_clues.append(clues)
143
+
144
+ # Start with empty grid
145
+ self.grid = [[-1 for _ in range(self.size)] for _ in range(self.size)]
146
+ self.initial_grid = [row[:] for row in self.grid]
147
+ self.moves_made = 0
148
+ self.game_started = True
149
+
150
+ async def validate_move(self, row: int, col: int, value: int) -> MoveResult:
151
+ """Mark a cell on the grid.
152
+
153
+ Args:
154
+ row: Row index (1-indexed, user-facing)
155
+ col: Column index (1-indexed, user-facing)
156
+ value: Value to place (0=empty/X, 1=filled/■, -1=unknown/clear)
157
+
158
+ Returns:
159
+ MoveResult with success status and message
160
+ """
161
+ # Convert to 0-indexed
162
+ row -= 1
163
+ col -= 1
164
+
165
+ # Validate coordinates
166
+ if not (0 <= row < self.size and 0 <= col < self.size):
167
+ return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
168
+
169
+ # Validate value
170
+ if value not in [-1, 0, 1]:
171
+ return MoveResult(success=False, message="Invalid value. Use 1 (filled), 0 (empty), or -1 (clear).")
172
+
173
+ self.grid[row][col] = value
174
+ self.moves_made += 1
175
+ return MoveResult(success=True, message="Cell marked successfully!", state_changed=True)
176
+
177
+ def is_complete(self) -> bool:
178
+ """Check if the puzzle is complete and correct."""
179
+ # Check all cells marked
180
+ for row in range(self.size):
181
+ for col in range(self.size):
182
+ if self.grid[row][col] == -1:
183
+ return False
184
+ if self.grid[row][col] != self.solution[row][col]:
185
+ return False
186
+
187
+ return True
188
+
189
+ async def get_hint(self) -> tuple[Any, str] | None:
190
+ """Get a hint for the next move.
191
+
192
+ Returns:
193
+ Tuple of (hint_data, hint_message) or None if puzzle is complete
194
+ """
195
+ unknown_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == -1]
196
+ if not unknown_cells:
197
+ return None
198
+
199
+ row, col = self._rng.choice(unknown_cells)
200
+ value = self.solution[row][col]
201
+ value_str = "filled (■)" if value == 1 else "empty (X)"
202
+ hint_data = (row + 1, col + 1, value)
203
+ hint_message = f"Try marking row {row + 1}, column {col + 1} as {value_str}"
204
+ return hint_data, hint_message
205
+
206
+ def render_grid(self) -> str:
207
+ """Render the current puzzle state as ASCII art.
208
+
209
+ Returns:
210
+ String representation of the puzzle grid with clues
211
+ """
212
+ lines = []
213
+
214
+ # Determine max clue length for formatting
215
+ max_row_clues = max(len(clues) for clues in self.row_clues)
216
+ max_col_clues = max(len(clues) for clues in self.col_clues)
217
+
218
+ # Render column clues
219
+ for clue_idx in range(max_col_clues):
220
+ line = " " * (max_row_clues * 2 + 2)
221
+ for col in range(self.size):
222
+ clues = self.col_clues[col]
223
+ # Pad clues from the top
224
+ padded_idx = clue_idx - (max_col_clues - len(clues))
225
+ if padded_idx >= 0:
226
+ line += f"{clues[padded_idx]:2d} "
227
+ else:
228
+ line += " "
229
+ lines.append(line)
230
+
231
+ lines.append(" " * (max_row_clues * 2 + 2) + "+" + "--+" * self.size)
232
+
233
+ # Render grid with row clues
234
+ for row in range(self.size):
235
+ # Row clues
236
+ clues = self.row_clues[row]
237
+ clue_str = " ".join(f"{c:2d}" for c in clues)
238
+ clue_str = clue_str.rjust(max_row_clues * 3)
239
+
240
+ # Grid row
241
+ line = clue_str + " |"
242
+ for col in range(self.size):
243
+ cell = self.grid[row][col]
244
+ if cell == -1:
245
+ line += " ? |"
246
+ elif cell == 0:
247
+ line += " X |"
248
+ else: # cell == 1
249
+ line += " ■ |"
250
+ lines.append(line)
251
+ lines.append(" " * (max_row_clues * 2 + 2) + "+" + "--+" * self.size)
252
+
253
+ lines.append("\nLegend: ? = unknown, X = empty, ■ = filled")
254
+
255
+ return "\n".join(lines)
256
+
257
+ def get_rules(self) -> str:
258
+ """Get the rules description for Nonogram.
259
+
260
+ Returns:
261
+ Multi-line string describing the puzzle rules
262
+ """
263
+ return f"""NONOGRAM RULES:
264
+ - Fill cells to reveal a picture
265
+ - Numbers on the left show consecutive filled cells in each row
266
+ - Numbers on the top show consecutive filled cells in each column
267
+ - Multiple numbers mean multiple groups with at least one empty cell between
268
+ - For example: [3, 1] means 3 filled, gap, 1 filled
269
+ - Mark cells as: 1 (filled/■), 0 (empty/X), or -1 (unknown/?)
270
+ - Grid size: {self.size}x{self.size}"""
271
+
272
+ def get_commands(self) -> str:
273
+ """Get the available commands for Nonogram.
274
+
275
+ Returns:
276
+ Multi-line string describing available commands
277
+ """
278
+ return """NONOGRAM COMMANDS:
279
+ place <row> <col> <val> - Mark cell: 1=filled(■), 0=empty(X), -1=clear(?)
280
+ Example: 'place 1 2 1' marks (1,2) as filled
281
+ show - Display the current grid
282
+ hint - Get a hint for the next move
283
+ check - Check your progress
284
+ solve - Show the solution (ends game)
285
+ menu - Return to game selection
286
+ quit - Exit the server"""
287
+
288
+ def get_stats(self) -> str:
289
+ """Get current game statistics.
290
+
291
+ Returns:
292
+ String with game stats
293
+ """
294
+ unknown = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == -1)
295
+ filled = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 1)
296
+ return f"Moves made: {self.moves_made} | Unknown: {unknown} | Filled: {filled}/{self.size * self.size} | Seed: {self.seed}"
@@ -0,0 +1,6 @@
1
+ """Nurikabe puzzle game module."""
2
+
3
+ from .config import NurikabeConfig
4
+ from .game import NurikabeGame
5
+
6
+ __all__ = ["NurikabeGame", "NurikabeConfig"]
@@ -0,0 +1,24 @@
1
+ """Configuration for Nurikabe game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class NurikabeConfig(BaseModel):
9
+ """Configuration for Nurikabe game."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
12
+ size: int = Field(ge=4, le=12, description="Grid size (NxN)")
13
+ num_islands: int = Field(ge=1, description="Number of islands")
14
+
15
+ @classmethod
16
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "NurikabeConfig":
17
+ """Create config from difficulty level."""
18
+ config_map = {
19
+ DifficultyLevel.EASY: {"size": 6, "num_islands": 3},
20
+ DifficultyLevel.MEDIUM: {"size": 8, "num_islands": 4},
21
+ DifficultyLevel.HARD: {"size": 10, "num_islands": 5},
22
+ }
23
+ params = config_map[difficulty]
24
+ return cls(difficulty=difficulty, **params)
@@ -0,0 +1,14 @@
1
+ """Nurikabe game enums."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class NurikabeColor(str, Enum):
7
+ """Colors for Nurikabe cells."""
8
+
9
+ WHITE = "white"
10
+ W = "w"
11
+ BLACK = "black"
12
+ B = "b"
13
+ CLEAR = "clear"
14
+ C = "c"