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,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,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)
|