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,586 @@
1
+ """Nurikabe 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 NurikabeConfig
8
+ from .enums import NurikabeColor
9
+
10
+
11
+ class NurikabeGame(PuzzleGame):
12
+ """Nurikabe puzzle game.
13
+
14
+ Create islands (white cells) and sea (black cells) where:
15
+ - Each numbered cell is part of a white island of that size
16
+ - All black cells are connected
17
+ - No 2×2 blocks of black cells
18
+ - All white cells in an island are connected
19
+ """
20
+
21
+ def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
22
+ """Initialize a new Nurikabe game.
23
+
24
+ Args:
25
+ difficulty: Game difficulty level (easy/medium/hard)
26
+ """
27
+ super().__init__(difficulty, seed, **kwargs)
28
+
29
+ # Load config from difficulty
30
+ self.config = NurikabeConfig.from_difficulty(self.difficulty)
31
+ self.size = self.config.size
32
+ self.num_islands = self.config.num_islands
33
+
34
+ # Grid: 0 = unknown, 1 = white (island), 2 = black (sea)
35
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
36
+ self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
37
+
38
+ # Clues: (row, col, size) - this cell is part of an island of this size
39
+ self.clues: list[tuple[int, int, int]] = []
40
+
41
+ # Islands: list of ((row, col), size) tuples
42
+ self.islands: list[tuple[tuple[int, int], int]] = []
43
+
44
+ # Given cells: set of (row, col) positions that show clue numbers
45
+ self.given_cells: set[tuple[int, int]] = set()
46
+
47
+ @property
48
+ def name(self) -> str:
49
+ """The display name of this puzzle type."""
50
+ return "Nurikabe"
51
+
52
+ @property
53
+ def description(self) -> str:
54
+ """A one-line description of this puzzle type."""
55
+ return "Create islands and sea - test connectivity reasoning"
56
+
57
+ @property
58
+ def constraint_types(self) -> list[str]:
59
+ """Constraint types demonstrated by this puzzle."""
60
+ return ["connectivity", "partition", "all_different_regions", "no_pools"]
61
+
62
+ @property
63
+ def business_analogies(self) -> list[str]:
64
+ """Business problems this puzzle models."""
65
+ return ["network_segmentation", "zone_planning", "cluster_analysis"]
66
+
67
+ @property
68
+ def complexity_profile(self) -> dict[str, str]:
69
+ """Complexity profile of this puzzle."""
70
+ return {"reasoning_type": "deductive", "search_space": "large", "constraint_density": "dense"}
71
+
72
+ @property
73
+ def optimal_steps(self) -> int | None:
74
+ """Minimum steps = all cells to mark (excluding clue cells)."""
75
+ if not hasattr(self, "solution") or not self.solution:
76
+ return None
77
+ # Solution has 1=island, 2=sea. Count all cells except we need to
78
+ # subtract clue cells which are already given
79
+ # Clues are stored separately, so count from solution
80
+ total_cells = self.size * self.size
81
+ # Count clue cells (they're integers > 0 in the clues dict or initial grid)
82
+ if hasattr(self, "clues") and self.clues:
83
+ num_clues = len(self.clues)
84
+ else:
85
+ num_clues = 0
86
+ return total_cells - num_clues
87
+
88
+ @property
89
+ def difficulty_profile(self) -> "DifficultyProfile":
90
+ """Difficulty characteristics for Nurikabe."""
91
+ from ...models import DifficultyLevel
92
+
93
+ logic_depth = {
94
+ DifficultyLevel.EASY.value: 3,
95
+ DifficultyLevel.MEDIUM.value: 5,
96
+ DifficultyLevel.HARD.value: 6,
97
+ }.get(self.difficulty.value, 4)
98
+ return DifficultyProfile(
99
+ logic_depth=logic_depth,
100
+ branching_factor=2.0, # Island or sea
101
+ state_observability=1.0,
102
+ constraint_density=0.6,
103
+ )
104
+
105
+ async def generate_puzzle(self) -> None:
106
+ """Generate a new Nurikabe puzzle with sophisticated algorithm."""
107
+ max_attempts = 100
108
+
109
+ for _attempt in range(max_attempts):
110
+ # Start with all black cells (sea)
111
+ self.solution = [[2 for _ in range(self.size)] for _ in range(self.size)]
112
+
113
+ self.clues = []
114
+ self.islands = []
115
+ self.given_cells = set()
116
+
117
+ # Step 1: Place islands strategically
118
+ placed_islands = self._place_separated_islands_v2()
119
+
120
+ # Step 2: Mark island cells as white in solution
121
+ for island_cells in placed_islands:
122
+ for r, c in island_cells:
123
+ self.solution[r][c] = 1
124
+
125
+ # Step 3: Fix any 2x2 black blocks iteratively
126
+ self._fix_2x2_blocks()
127
+
128
+ # Step 4: Validate the solution
129
+ if self._validate_solution():
130
+ break
131
+
132
+ # Initialize player grid
133
+ self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
134
+
135
+ # Place clue numbers
136
+ for row, col, _size in self.clues:
137
+ self.grid[row][col] = 1 # Mark as white
138
+
139
+ self.moves_made = 0
140
+ self.game_started = True
141
+
142
+ def _place_separated_islands_v2(self) -> list[list[tuple[int, int]]]:
143
+ """Place islands at well-separated positions (simple version)."""
144
+ placed_islands = []
145
+
146
+ # Define island sizes
147
+ island_sizes = []
148
+ for i in range(self.num_islands):
149
+ if i == 0:
150
+ island_sizes.append(3) # First island is size 3
151
+ else:
152
+ island_sizes.append(2) # Others are size 2
153
+
154
+ # Use simple corner/center positions with good spacing
155
+ positions = [
156
+ (0, 0), # Top-left
157
+ (0, self.size - 1), # Top-right
158
+ (self.size - 1, 0), # Bottom-left
159
+ (self.size - 1, self.size - 1), # Bottom-right
160
+ (self.size // 2, self.size // 2), # Center
161
+ ]
162
+
163
+ for i, size in enumerate(island_sizes):
164
+ if i >= len(positions):
165
+ break
166
+
167
+ start_row, start_col = positions[i]
168
+ island_cells = [(start_row, start_col)]
169
+
170
+ # Add adjacent cells to form island
171
+ neighbors = [
172
+ (start_row - 1, start_col),
173
+ (start_row + 1, start_col),
174
+ (start_row, start_col - 1),
175
+ (start_row, start_col + 1),
176
+ ]
177
+
178
+ for nr, nc in neighbors:
179
+ if len(island_cells) >= size:
180
+ break
181
+ if 0 <= nr < self.size and 0 <= nc < self.size:
182
+ island_cells.append((nr, nc))
183
+
184
+ # Trim to exact size
185
+ island_cells = island_cells[:size]
186
+
187
+ if len(island_cells) >= 2: # Only add if we have at least 2 cells
188
+ placed_islands.append(island_cells)
189
+ clue_cell = island_cells[0]
190
+ self.clues.append((clue_cell[0], clue_cell[1], len(island_cells)))
191
+ self.islands.append((clue_cell, len(island_cells)))
192
+ self.given_cells.add(clue_cell)
193
+
194
+ return placed_islands
195
+
196
+ def _fix_2x2_blocks(self) -> None:
197
+ """Iteratively fix any 2x2 black blocks."""
198
+ # Track which cells belong to which island and the max sizes
199
+ island_map = {} # (row, col) -> island_id
200
+ island_sizes = {} # island_id -> (current_size, max_size)
201
+
202
+ for island_id, (clue_pos, max_size) in enumerate(self.islands):
203
+ clue_r, clue_c = clue_pos
204
+ island = self._get_island_from_cell_in_solution(clue_r, clue_c)
205
+ for r, c in island:
206
+ island_map[(r, c)] = island_id
207
+ island_sizes[island_id] = (len(island), max_size)
208
+
209
+ max_iterations = 100
210
+ iteration = 0
211
+
212
+ while iteration < max_iterations:
213
+ iteration += 1
214
+ found_2x2 = False
215
+
216
+ # Scan for 2x2 black blocks
217
+ for row in range(self.size - 1):
218
+ for col in range(self.size - 1):
219
+ if (
220
+ self.solution[row][col] == 2
221
+ and self.solution[row][col + 1] == 2
222
+ and self.solution[row + 1][col] == 2
223
+ and self.solution[row + 1][col + 1] == 2
224
+ ):
225
+ found_2x2 = True
226
+
227
+ # Try to convert one cell to white without merging islands
228
+ # Prefer cells that are not part of given islands
229
+ candidates = [(row, col), (row, col + 1), (row + 1, col), (row + 1, col + 1)]
230
+
231
+ converted = False
232
+ for r, c in candidates:
233
+ if (r, c) not in self.given_cells:
234
+ # Check neighbor islands
235
+ neighbor_islands = set()
236
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
237
+ nr, nc = r + dr, c + dc
238
+ if 0 <= nr < self.size and 0 <= nc < self.size:
239
+ if self.solution[nr][nc] == 1 and (nr, nc) in island_map:
240
+ neighbor_islands.add(island_map[(nr, nc)])
241
+
242
+ # Only convert if it won't merge different islands
243
+ # AND won't make an island too large
244
+ # AND won't disconnect the black sea
245
+ can_add = True
246
+ if len(neighbor_islands) == 1:
247
+ island_id = list(neighbor_islands)[0]
248
+ current_size, max_size = island_sizes[island_id]
249
+ if current_size >= max_size:
250
+ can_add = False # Island is already full
251
+ elif len(neighbor_islands) > 1:
252
+ can_add = False # Would merge islands
253
+
254
+ if can_add:
255
+ # Temporarily convert and check black connectivity
256
+ self.solution[r][c] = 1
257
+ temp_grid = self.grid
258
+ self.grid = self.solution
259
+ black_connected = self._check_black_connected()
260
+ self.grid = temp_grid
261
+
262
+ if black_connected:
263
+ # Update island map if this extends an existing island
264
+ if len(neighbor_islands) == 1:
265
+ island_id = list(neighbor_islands)[0]
266
+ island_map[(r, c)] = island_id
267
+ current_size, max_size = island_sizes[island_id]
268
+ island_sizes[island_id] = (current_size + 1, max_size)
269
+ converted = True
270
+ break
271
+ else:
272
+ # Revert the change
273
+ self.solution[r][c] = 2
274
+
275
+ # If we couldn't convert safely, this generation attempt failed
276
+ # The outer loop will retry with a new random arrangement
277
+ if not converted:
278
+ # Mark this as invalid by returning early
279
+ # The validation will fail and trigger a retry
280
+ pass
281
+
282
+ if not found_2x2:
283
+ break
284
+
285
+ def _get_island_from_cell_in_solution(self, row: int, col: int) -> set[tuple[int, int]]:
286
+ """Get all white cells connected to the given cell in solution."""
287
+ if self.solution[row][col] != 1:
288
+ return set()
289
+
290
+ island = set()
291
+ queue = [(row, col)]
292
+ island.add((row, col))
293
+
294
+ while queue:
295
+ r, c = queue.pop(0)
296
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
297
+ nr, nc = r + dr, c + dc
298
+ if 0 <= nr < self.size and 0 <= nc < self.size:
299
+ if (nr, nc) not in island and self.solution[nr][nc] == 1:
300
+ island.add((nr, nc))
301
+ queue.append((nr, nc))
302
+
303
+ return island
304
+
305
+ def _validate_solution(self) -> bool:
306
+ """Validate that the generated solution meets all Nurikabe rules."""
307
+ # Temporarily use solution as grid for validation
308
+ temp_grid = self.grid
309
+ self.grid = self.solution
310
+
311
+ # Check that each clue cell has an island of the correct size
312
+ for clue_row, clue_col, expected_size in self.clues:
313
+ island_cells = self._get_island_from_cell(clue_row, clue_col)
314
+ if len(island_cells) != expected_size:
315
+ self.grid = temp_grid
316
+ return False
317
+
318
+ # Check that black cells are connected
319
+ if not self._check_black_connected():
320
+ self.grid = temp_grid
321
+ return False
322
+
323
+ # Check no 2x2 black blocks
324
+ if self._has_2x2_black():
325
+ self.grid = temp_grid
326
+ return False
327
+
328
+ self.grid = temp_grid
329
+ return True
330
+
331
+ async def validate_move(self, row: int, col: int, color: str) -> MoveResult:
332
+ """Mark a cell as black or white.
333
+
334
+ Args:
335
+ row: Row index (1-indexed, user-facing)
336
+ col: Column index (1-indexed, user-facing)
337
+ color: 'white' or 'black'
338
+
339
+ Returns:
340
+ MoveResult with success status and message
341
+ """
342
+ # Convert to 0-indexed
343
+ row -= 1
344
+ col -= 1
345
+
346
+ # Validate coordinates
347
+ if not (0 <= row < self.size and 0 <= col < self.size):
348
+ return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
349
+
350
+ # Validate color with enum
351
+ try:
352
+ color_enum = NurikabeColor(color.lower())
353
+ except ValueError:
354
+ return MoveResult(success=False, message="Invalid color. Use 'white', 'black', or 'clear'.")
355
+
356
+ # Check if this is a clue cell
357
+ for clue_row, clue_col, _size in self.clues:
358
+ if row == clue_row and col == clue_col:
359
+ return MoveResult(success=False, message="Cannot modify clue cells.")
360
+
361
+ if color_enum in (NurikabeColor.WHITE, NurikabeColor.W):
362
+ self.grid[row][col] = 1
363
+ self.moves_made += 1
364
+ return MoveResult(success=True, message="Cell marked as white (island).", state_changed=True)
365
+ elif color_enum in (NurikabeColor.BLACK, NurikabeColor.B):
366
+ self.grid[row][col] = 2
367
+ self.moves_made += 1
368
+ return MoveResult(success=True, message="Cell marked as black (sea).", state_changed=True)
369
+ elif color_enum in (NurikabeColor.CLEAR, NurikabeColor.C):
370
+ # Don't clear clue cells
371
+ for clue_row, clue_col, _size in self.clues:
372
+ if row == clue_row and col == clue_col:
373
+ return MoveResult(success=False, message="Cannot clear clue cells.")
374
+ # Check if cell is already unmarked
375
+ if self.grid[row][col] == 0:
376
+ return MoveResult(success=False, message="Cell is already unmarked.")
377
+ self.grid[row][col] = 0
378
+ self.moves_made += 1
379
+ return MoveResult(success=True, message="Cell cleared.", state_changed=True)
380
+
381
+ return MoveResult(success=False, message="Invalid color. Use 'white', 'black', or 'clear'.")
382
+
383
+ def is_complete(self) -> bool:
384
+ """Check if the puzzle is complete and correct."""
385
+ # All cells must be filled
386
+ for row in range(self.size):
387
+ for col in range(self.size):
388
+ if self.grid[row][col] == 0:
389
+ return False
390
+
391
+ # Check each clue has an island of correct size
392
+ for clue_row, clue_col, island_size in self.clues:
393
+ island = self._get_island_from_cell(clue_row, clue_col)
394
+ if len(island) != island_size:
395
+ return False
396
+
397
+ # Check all black cells are connected
398
+ if not self._check_black_connected():
399
+ return False
400
+
401
+ # Check no 2×2 blocks of black
402
+ if self._has_2x2_black():
403
+ return False
404
+
405
+ return True
406
+
407
+ def _get_island_from_cell(self, row: int, col: int) -> set[tuple[int, int]]:
408
+ """Get all cells in the white island containing this cell using BFS."""
409
+ if self.grid[row][col] != 1:
410
+ return set()
411
+
412
+ island = set()
413
+ queue = [(row, col)]
414
+ island.add((row, col))
415
+
416
+ while queue:
417
+ r, c = queue.pop(0)
418
+
419
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
420
+ nr, nc = r + dr, c + dc
421
+
422
+ if 0 <= nr < self.size and 0 <= nc < self.size:
423
+ if (nr, nc) not in island and self.grid[nr][nc] == 1:
424
+ island.add((nr, nc))
425
+ queue.append((nr, nc))
426
+
427
+ return island
428
+
429
+ def _check_black_connected(self) -> bool:
430
+ """Check if all black cells form a single connected component."""
431
+ # Find first black cell
432
+ first_black = None
433
+ black_count = 0
434
+
435
+ for row in range(self.size):
436
+ for col in range(self.size):
437
+ if self.grid[row][col] == 2:
438
+ black_count += 1
439
+ if first_black is None:
440
+ first_black = (row, col)
441
+
442
+ if black_count == 0:
443
+ return True # No black cells is technically connected
444
+
445
+ # BFS from first black cell
446
+ visited = set()
447
+ queue = [first_black]
448
+ visited.add(first_black)
449
+
450
+ while queue:
451
+ row, col = queue.pop(0)
452
+
453
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
454
+ nr, nc = row + dr, col + dc
455
+
456
+ if 0 <= nr < self.size and 0 <= nc < self.size:
457
+ if (nr, nc) not in visited and self.grid[nr][nc] == 2:
458
+ visited.add((nr, nc))
459
+ queue.append((nr, nc))
460
+
461
+ return len(visited) == black_count
462
+
463
+ def _has_2x2_black(self) -> bool:
464
+ """Check if there are any 2×2 blocks of black cells."""
465
+ for row in range(self.size - 1):
466
+ for col in range(self.size - 1):
467
+ if (
468
+ self.grid[row][col] == 2
469
+ and self.grid[row][col + 1] == 2
470
+ and self.grid[row + 1][col] == 2
471
+ and self.grid[row + 1][col + 1] == 2
472
+ ):
473
+ return True
474
+ return False
475
+
476
+ async def get_hint(self) -> tuple[Any, str] | None:
477
+ """Get a hint for the next move.
478
+
479
+ Returns:
480
+ Tuple of (hint_data, hint_message) or None
481
+ """
482
+ # Find a cell that differs from solution
483
+ for row in range(self.size):
484
+ for col in range(self.size):
485
+ # Skip clue cells
486
+ is_clue = any(r == row and c == col for r, c, _ in self.clues)
487
+ if is_clue:
488
+ continue
489
+
490
+ if self.grid[row][col] != self.solution[row][col]:
491
+ color = "white" if self.solution[row][col] == 1 else "black"
492
+ hint_data = (row + 1, col + 1, color)
493
+ hint_message = f"Try marking ({row + 1},{col + 1}) as {color}"
494
+ return hint_data, hint_message
495
+
496
+ return None
497
+
498
+ def render_grid(self) -> str:
499
+ """Render the current puzzle state as ASCII art.
500
+
501
+ Returns:
502
+ String representation of the puzzle grid
503
+ """
504
+ lines = []
505
+
506
+ lines.append("Nurikabe")
507
+ lines.append(f"Islands: {len(self.islands)}")
508
+ lines.append("")
509
+
510
+ # Header
511
+ header = " |"
512
+ for i in range(self.size):
513
+ header += f"{i + 1}|"
514
+ lines.append(header)
515
+ lines.append(" +" + "-+" * self.size)
516
+
517
+ # Grid rows
518
+ for row in range(self.size):
519
+ line = f"{row + 1} |"
520
+
521
+ for col in range(self.size):
522
+ # Check if this is a clue cell
523
+ clue_value = None
524
+ for clue_row, clue_col, size in self.clues:
525
+ if row == clue_row and col == clue_col:
526
+ clue_value = size
527
+ break
528
+
529
+ if clue_value is not None:
530
+ line += f"{clue_value}|"
531
+ elif self.grid[row][col] == 0:
532
+ line += ".|"
533
+ elif self.grid[row][col] == 1:
534
+ line += "○|" # White (island)
535
+ elif self.grid[row][col] == 2:
536
+ line += "●|" # Black (sea)
537
+
538
+ lines.append(line)
539
+ lines.append(" +" + "-+" * self.size)
540
+
541
+ lines.append("")
542
+ lines.append("Legend: Numbers = island size, ○ = white (island), ● = black (sea), . = unknown")
543
+
544
+ return "\n".join(lines)
545
+
546
+ def get_rules(self) -> str:
547
+ """Get the rules description for Nurikabe.
548
+
549
+ Returns:
550
+ Multi-line string describing the puzzle rules
551
+ """
552
+ return """NURIKABE RULES:
553
+ - Numbers indicate island sizes (connected white cells)
554
+ - Each number must be part of an island of that size
555
+ - All black cells (sea) must be connected
556
+ - No 2×2 blocks of black cells allowed
557
+ - White cells in same island must be connected
558
+ - All cells must be either white (island) or black (sea)"""
559
+
560
+ def get_commands(self) -> str:
561
+ """Get the available commands for Nurikabe.
562
+
563
+ Returns:
564
+ Multi-line string describing available commands
565
+ """
566
+ return """NURIKABE COMMANDS:
567
+ mark <row> <col> white - Mark cell as white/island (e.g., 'mark 2 3 white')
568
+ mark <row> <col> black - Mark cell as black/sea
569
+ mark <row> <col> clear - Clear a cell
570
+ show - Display the current grid
571
+ hint - Get a hint for the next move
572
+ check - Check your progress
573
+ solve - Show the solution (ends game)
574
+ menu - Return to game selection
575
+ quit - Exit the server"""
576
+
577
+ def get_stats(self) -> str:
578
+ """Get current game statistics.
579
+
580
+ Returns:
581
+ String with game stats
582
+ """
583
+ marked = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] != 0)
584
+ total = self.size * self.size
585
+
586
+ return f"Moves made: {self.moves_made} | Marked: {marked}/{total} cells | Islands: {len(self.islands)} | Seed: {self.seed}"
@@ -0,0 +1,6 @@
1
+ """Scheduler puzzle game module."""
2
+
3
+ from .config import SchedulerConfig
4
+ from .game import SchedulerGame
5
+
6
+ __all__ = ["SchedulerGame", "SchedulerConfig"]
@@ -0,0 +1,25 @@
1
+ """Configuration for Scheduler game."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...models.enums import DifficultyLevel
6
+
7
+
8
+ class SchedulerConfig(BaseModel):
9
+ """Configuration for Scheduler game."""
10
+
11
+ difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
12
+ num_tasks: int = Field(ge=1, le=20, description="Number of tasks")
13
+ num_workers: int = Field(ge=1, le=10, description="Number of workers")
14
+ dependency_prob: float = Field(ge=0.0, le=1.0, description="Probability of task dependencies")
15
+
16
+ @classmethod
17
+ def from_difficulty(cls, difficulty: DifficultyLevel) -> "SchedulerConfig":
18
+ """Create config from difficulty level."""
19
+ config_map = {
20
+ DifficultyLevel.EASY: {"num_tasks": 4, "num_workers": 2, "dependency_prob": 0.3},
21
+ DifficultyLevel.MEDIUM: {"num_tasks": 6, "num_workers": 2, "dependency_prob": 0.4},
22
+ DifficultyLevel.HARD: {"num_tasks": 8, "num_workers": 3, "dependency_prob": 0.5},
23
+ }
24
+ params = config_map[difficulty]
25
+ return cls(difficulty=difficulty, **params)
@@ -0,0 +1,15 @@
1
+ """Task Scheduler puzzle constants and static data."""
2
+
3
+ from typing import Final
4
+
5
+ # Task names
6
+ TASK_NAMES: Final[list[str]] = [
7
+ "Task A",
8
+ "Task B",
9
+ "Task C",
10
+ "Task D",
11
+ "Task E",
12
+ "Task F",
13
+ "Task G",
14
+ "Task H",
15
+ ]
@@ -0,0 +1,10 @@
1
+ """Scheduler game enums."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class SchedulerAction(str, Enum):
7
+ """Actions for Scheduler game."""
8
+
9
+ ASSIGN = "assign"
10
+ UNASSIGN = "unassign"