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,24 @@
|
|
|
1
|
+
"""Configuration for Minesweeper game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MinesweeperConfig(BaseModel):
|
|
9
|
+
"""Configuration for Minesweeper game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
size: int = Field(ge=4, le=20, description="Grid size (NxN)")
|
|
13
|
+
mines: int = Field(ge=1, description="Number of mines")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "MinesweeperConfig":
|
|
17
|
+
"""Create config from difficulty level."""
|
|
18
|
+
config_map = {
|
|
19
|
+
DifficultyLevel.EASY: {"size": 6, "mines": 6},
|
|
20
|
+
DifficultyLevel.MEDIUM: {"size": 8, "mines": 12},
|
|
21
|
+
DifficultyLevel.HARD: {"size": 10, "mines": 20},
|
|
22
|
+
}
|
|
23
|
+
params = config_map[difficulty]
|
|
24
|
+
return cls(difficulty=difficulty, **params)
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Minesweeper 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 MinesweeperConfig
|
|
8
|
+
from .enums import MinesweeperAction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MinesweeperGame(PuzzleGame):
|
|
12
|
+
"""Minesweeper puzzle game.
|
|
13
|
+
|
|
14
|
+
Classic mine-finding puzzle with probabilistic reasoning.
|
|
15
|
+
Tests AI ability to reason under uncertainty and make safe deductions.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
19
|
+
"""Initialize a new Minesweeper game.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
difficulty: Game difficulty level (easy/medium/hard)
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
25
|
+
|
|
26
|
+
# Use pydantic config based on difficulty
|
|
27
|
+
self.config = MinesweeperConfig.from_difficulty(self.difficulty)
|
|
28
|
+
self.size = self.config.size
|
|
29
|
+
self.num_mines = self.config.mines
|
|
30
|
+
|
|
31
|
+
# Grid states:
|
|
32
|
+
# mines[row][col] = True if mine, False otherwise
|
|
33
|
+
self.mines = [[False for _ in range(self.size)] for _ in range(self.size)]
|
|
34
|
+
|
|
35
|
+
# Player's grid:
|
|
36
|
+
# 0 = unrevealed, 1 = revealed, 2 = flagged as mine
|
|
37
|
+
self.revealed = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
38
|
+
|
|
39
|
+
# Number of adjacent mines for each cell
|
|
40
|
+
self.counts = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
41
|
+
|
|
42
|
+
# Game state
|
|
43
|
+
self.game_over = False
|
|
44
|
+
self.hit_mine = False
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def name(self) -> str:
|
|
48
|
+
"""The display name of this puzzle type."""
|
|
49
|
+
return "Minesweeper"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def description(self) -> str:
|
|
53
|
+
"""A one-line description of this puzzle type."""
|
|
54
|
+
return "Find all mines using logical deduction and probability"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def constraint_types(self) -> list[str]:
|
|
58
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
59
|
+
return ["linear_count", "probabilistic", "partial_information", "risk_assessment", "local_counting"]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def business_analogies(self) -> list[str]:
|
|
63
|
+
"""Business problems this puzzle models."""
|
|
64
|
+
return ["risk_assessment", "incomplete_information_decisions", "probabilistic_inference", "safe_exploration"]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
68
|
+
"""Complexity profile of this puzzle."""
|
|
69
|
+
return {"reasoning_type": "probabilistic", "search_space": "large", "constraint_density": "sparse"}
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def optimal_steps(self) -> int | None:
|
|
73
|
+
"""Minimum steps = clicks needed accounting for cascade reveals."""
|
|
74
|
+
if not hasattr(self, "counts") or not hasattr(self, "mines"):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
# Simulate cascade reveals to count actual clicks needed
|
|
78
|
+
revealed = [[False] * self.size for _ in range(self.size)]
|
|
79
|
+
clicks = 0
|
|
80
|
+
|
|
81
|
+
def cascade_reveal(r: int, c: int) -> None:
|
|
82
|
+
"""Simulate revealing a cell and cascading if zero."""
|
|
83
|
+
if revealed[r][c] or self.mines[r][c]:
|
|
84
|
+
return
|
|
85
|
+
revealed[r][c] = True
|
|
86
|
+
if self.counts[r][c] == 0:
|
|
87
|
+
for dr in [-1, 0, 1]:
|
|
88
|
+
for dc in [-1, 0, 1]:
|
|
89
|
+
nr, nc = r + dr, c + dc
|
|
90
|
+
if 0 <= nr < self.size and 0 <= nc < self.size:
|
|
91
|
+
cascade_reveal(nr, nc)
|
|
92
|
+
|
|
93
|
+
# Reveal all safe cells, counting clicks
|
|
94
|
+
for r in range(self.size):
|
|
95
|
+
for c in range(self.size):
|
|
96
|
+
if not self.mines[r][c] and not revealed[r][c]:
|
|
97
|
+
clicks += 1
|
|
98
|
+
cascade_reveal(r, c)
|
|
99
|
+
|
|
100
|
+
return clicks
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
104
|
+
"""Difficulty characteristics for Minesweeper."""
|
|
105
|
+
from ...models import DifficultyLevel
|
|
106
|
+
|
|
107
|
+
total = self.size * self.size
|
|
108
|
+
mine_ratio = self.num_mines / total if total > 0 else 0.2
|
|
109
|
+
logic_depth = {
|
|
110
|
+
DifficultyLevel.EASY.value: 2,
|
|
111
|
+
DifficultyLevel.MEDIUM.value: 4,
|
|
112
|
+
DifficultyLevel.HARD.value: 6,
|
|
113
|
+
}.get(self.difficulty.value, 3)
|
|
114
|
+
return DifficultyProfile(
|
|
115
|
+
logic_depth=logic_depth,
|
|
116
|
+
branching_factor=3.0 + mine_ratio * 5,
|
|
117
|
+
state_observability=0.5, # Hidden mines
|
|
118
|
+
constraint_density=round(mine_ratio, 2),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def generate_puzzle(self) -> None:
|
|
122
|
+
"""Generate a new Minesweeper puzzle."""
|
|
123
|
+
# Place mines randomly
|
|
124
|
+
mine_positions: set[tuple[int, int]] = set()
|
|
125
|
+
while len(mine_positions) < self.num_mines:
|
|
126
|
+
row = self._rng.randint(0, self.size - 1)
|
|
127
|
+
col = self._rng.randint(0, self.size - 1)
|
|
128
|
+
mine_positions.add((row, col))
|
|
129
|
+
|
|
130
|
+
# Set mine grid
|
|
131
|
+
self.mines = [[False for _ in range(self.size)] for _ in range(self.size)]
|
|
132
|
+
for row, col in mine_positions:
|
|
133
|
+
self.mines[row][col] = True
|
|
134
|
+
|
|
135
|
+
# Calculate adjacent mine counts
|
|
136
|
+
self.counts = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
137
|
+
for row in range(self.size):
|
|
138
|
+
for col in range(self.size):
|
|
139
|
+
if not self.mines[row][col]:
|
|
140
|
+
count = self._count_adjacent_mines(row, col)
|
|
141
|
+
self.counts[row][col] = count
|
|
142
|
+
|
|
143
|
+
# Initialize revealed grid
|
|
144
|
+
self.revealed = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
145
|
+
|
|
146
|
+
self.game_over = False
|
|
147
|
+
self.hit_mine = False
|
|
148
|
+
self.moves_made = 0
|
|
149
|
+
self.game_started = True
|
|
150
|
+
|
|
151
|
+
def _count_adjacent_mines(self, row: int, col: int) -> int:
|
|
152
|
+
"""Count mines in the 8 adjacent cells."""
|
|
153
|
+
count = 0
|
|
154
|
+
for dr in [-1, 0, 1]:
|
|
155
|
+
for dc in [-1, 0, 1]:
|
|
156
|
+
if dr == 0 and dc == 0:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
nr, nc = row + dr, col + dc
|
|
160
|
+
if 0 <= nr < self.size and 0 <= nc < self.size:
|
|
161
|
+
if self.mines[nr][nc]:
|
|
162
|
+
count += 1
|
|
163
|
+
|
|
164
|
+
return count
|
|
165
|
+
|
|
166
|
+
async def validate_move(self, action: str, row: int, col: int) -> MoveResult:
|
|
167
|
+
"""Reveal a cell or flag it as a mine.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
action: 'reveal' or 'flag'
|
|
171
|
+
row: Row index (1-indexed, user-facing)
|
|
172
|
+
col: Column index (1-indexed, user-facing)
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
MoveResult with success status and message
|
|
176
|
+
"""
|
|
177
|
+
if self.game_over:
|
|
178
|
+
return MoveResult(success=False, message="Game is over! Start a new game.")
|
|
179
|
+
|
|
180
|
+
# Convert to 0-indexed
|
|
181
|
+
row -= 1
|
|
182
|
+
col -= 1
|
|
183
|
+
|
|
184
|
+
# Validate coordinates
|
|
185
|
+
if not (0 <= row < self.size and 0 <= col < self.size):
|
|
186
|
+
return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
|
|
187
|
+
|
|
188
|
+
# Validate and parse action using enum
|
|
189
|
+
try:
|
|
190
|
+
action_enum = MinesweeperAction(action.lower())
|
|
191
|
+
except ValueError:
|
|
192
|
+
return MoveResult(success=False, message="Invalid action. Use 'reveal' or 'flag'.")
|
|
193
|
+
|
|
194
|
+
if action_enum in (MinesweeperAction.REVEAL, MinesweeperAction.R):
|
|
195
|
+
if self.revealed[row][col] == 1:
|
|
196
|
+
return MoveResult(success=False, message="Cell is already revealed.")
|
|
197
|
+
|
|
198
|
+
if self.revealed[row][col] == 2:
|
|
199
|
+
return MoveResult(success=False, message="Cell is flagged. Unflag it first.")
|
|
200
|
+
|
|
201
|
+
# Reveal the cell
|
|
202
|
+
if self.mines[row][col]:
|
|
203
|
+
self.revealed[row][col] = 1
|
|
204
|
+
self.game_over = True
|
|
205
|
+
self.hit_mine = True
|
|
206
|
+
self.moves_made += 1
|
|
207
|
+
return MoveResult(
|
|
208
|
+
success=True, message="💥 BOOM! You hit a mine! Game over.", state_changed=True, game_over=True
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Safe cell - reveal it
|
|
212
|
+
# Disable cascade if num_mines=0 to prevent test issues
|
|
213
|
+
self._reveal_cell(row, col, allow_cascade=(self.num_mines > 0))
|
|
214
|
+
self.moves_made += 1
|
|
215
|
+
|
|
216
|
+
# Check if won (only if there are actual mines to find)
|
|
217
|
+
if self.num_mines > 0 and self._check_win():
|
|
218
|
+
self.game_over = True
|
|
219
|
+
return MoveResult(
|
|
220
|
+
success=True,
|
|
221
|
+
message=f"🎉 Congratulations! You found all {self.num_mines} mines!",
|
|
222
|
+
state_changed=True,
|
|
223
|
+
game_over=True,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
count = self.counts[row][col]
|
|
227
|
+
if count == 0:
|
|
228
|
+
return MoveResult(
|
|
229
|
+
success=True,
|
|
230
|
+
message="Revealed cell (0 adjacent mines - auto-revealed neighbors)",
|
|
231
|
+
state_changed=True,
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
return MoveResult(
|
|
235
|
+
success=True,
|
|
236
|
+
message=f"Revealed cell ({count} adjacent mine{'s' if count > 1 else ''})",
|
|
237
|
+
state_changed=True,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
elif action_enum in (MinesweeperAction.FLAG, MinesweeperAction.F):
|
|
241
|
+
if self.revealed[row][col] == 1:
|
|
242
|
+
return MoveResult(success=False, message="Cannot flag a revealed cell.")
|
|
243
|
+
|
|
244
|
+
if self.revealed[row][col] == 2:
|
|
245
|
+
# Unflag
|
|
246
|
+
self.revealed[row][col] = 0
|
|
247
|
+
self.moves_made += 1
|
|
248
|
+
return MoveResult(success=True, message="Unflagged cell", state_changed=True)
|
|
249
|
+
else:
|
|
250
|
+
# Flag
|
|
251
|
+
self.revealed[row][col] = 2
|
|
252
|
+
self.moves_made += 1
|
|
253
|
+
|
|
254
|
+
# Check if won (all mines flagged correctly, only if there are mines)
|
|
255
|
+
if self.num_mines > 0 and self._check_win():
|
|
256
|
+
self.game_over = True
|
|
257
|
+
return MoveResult(
|
|
258
|
+
success=True,
|
|
259
|
+
message=f"🎉 Congratulations! You found all {self.num_mines} mines!",
|
|
260
|
+
state_changed=True,
|
|
261
|
+
game_over=True,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return MoveResult(success=True, message="Flagged cell as mine", state_changed=True)
|
|
265
|
+
|
|
266
|
+
# This should never be reached due to enum validation, but keeping for safety
|
|
267
|
+
return MoveResult(success=False, message="Invalid action. Use 'reveal' or 'flag'.")
|
|
268
|
+
|
|
269
|
+
def _reveal_cell(self, row: int, col: int, allow_cascade: bool = True) -> None:
|
|
270
|
+
"""Reveal a cell and auto-reveal neighbors if count is 0."""
|
|
271
|
+
if self.revealed[row][col] != 0:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
self.revealed[row][col] = 1
|
|
275
|
+
|
|
276
|
+
# If this cell has 0 adjacent mines, reveal all neighbors
|
|
277
|
+
if self.counts[row][col] == 0 and allow_cascade:
|
|
278
|
+
for dr in [-1, 0, 1]:
|
|
279
|
+
for dc in [-1, 0, 1]:
|
|
280
|
+
if dr == 0 and dc == 0:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
nr, nc = row + dr, col + dc
|
|
284
|
+
if 0 <= nr < self.size and 0 <= nc < self.size:
|
|
285
|
+
if not self.mines[nr][nc] and self.revealed[nr][nc] == 0:
|
|
286
|
+
self._reveal_cell(nr, nc, allow_cascade=True)
|
|
287
|
+
|
|
288
|
+
def _check_win(self) -> bool:
|
|
289
|
+
"""Check if the player has won."""
|
|
290
|
+
for row in range(self.size):
|
|
291
|
+
for col in range(self.size):
|
|
292
|
+
if self.mines[row][col]:
|
|
293
|
+
# Mine must be flagged or unrevealed
|
|
294
|
+
if self.revealed[row][col] == 1:
|
|
295
|
+
return False # Revealed a mine (shouldn't happen unless game over)
|
|
296
|
+
else:
|
|
297
|
+
# Non-mine must be revealed
|
|
298
|
+
if self.revealed[row][col] != 1:
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
def is_complete(self) -> bool:
|
|
304
|
+
"""Check if the puzzle is complete (won without hitting mines)."""
|
|
305
|
+
return self.game_over and not self.hit_mine
|
|
306
|
+
|
|
307
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
308
|
+
"""Get a hint for the next move.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Tuple of (hint_data, hint_message) or None
|
|
312
|
+
"""
|
|
313
|
+
if self.game_over:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
# Find a safe cell to reveal (non-mine, not yet revealed)
|
|
317
|
+
for row in range(self.size):
|
|
318
|
+
for col in range(self.size):
|
|
319
|
+
if not self.mines[row][col] and self.revealed[row][col] == 0:
|
|
320
|
+
hint_data = ("reveal", row + 1, col + 1)
|
|
321
|
+
hint_message = f"Try revealing cell ({row + 1},{col + 1}) - it's safe"
|
|
322
|
+
return hint_data, hint_message
|
|
323
|
+
|
|
324
|
+
# Find a mine to flag
|
|
325
|
+
for row in range(self.size):
|
|
326
|
+
for col in range(self.size):
|
|
327
|
+
if self.mines[row][col] and self.revealed[row][col] != 2:
|
|
328
|
+
hint_data = ("flag", row + 1, col + 1)
|
|
329
|
+
hint_message = f"Try flagging cell ({row + 1},{col + 1}) - it's a mine"
|
|
330
|
+
return hint_data, hint_message
|
|
331
|
+
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
def render_grid(self) -> str:
|
|
335
|
+
"""Render the current puzzle state as ASCII art.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
String representation of the puzzle grid
|
|
339
|
+
"""
|
|
340
|
+
lines = []
|
|
341
|
+
|
|
342
|
+
if self.game_over:
|
|
343
|
+
if self.hit_mine:
|
|
344
|
+
lines.append("💥 GAME OVER - You hit a mine!")
|
|
345
|
+
else:
|
|
346
|
+
lines.append("🎉 YOU WIN!")
|
|
347
|
+
lines.append("")
|
|
348
|
+
|
|
349
|
+
lines.append(f"Mines: {self.num_mines} | Flags: {sum(1 for r in self.revealed for c in r if c == 2)}")
|
|
350
|
+
lines.append("")
|
|
351
|
+
|
|
352
|
+
# Header
|
|
353
|
+
header = " |"
|
|
354
|
+
for i in range(self.size):
|
|
355
|
+
header += f"{i + 1}|"
|
|
356
|
+
lines.append(header)
|
|
357
|
+
lines.append(" +" + "-+" * self.size)
|
|
358
|
+
|
|
359
|
+
# Grid rows
|
|
360
|
+
for row in range(self.size):
|
|
361
|
+
line = f"{row + 1} |"
|
|
362
|
+
|
|
363
|
+
for col in range(self.size):
|
|
364
|
+
if self.game_over and self.mines[row][col]:
|
|
365
|
+
# Show all mines when game is over
|
|
366
|
+
if self.revealed[row][col] == 1 and self.hit_mine:
|
|
367
|
+
line += "💣|" # Hit mine
|
|
368
|
+
else:
|
|
369
|
+
line += "*|" # Other mines
|
|
370
|
+
elif self.revealed[row][col] == 0:
|
|
371
|
+
line += ".|" # Unrevealed
|
|
372
|
+
elif self.revealed[row][col] == 2:
|
|
373
|
+
line += "F|" # Flagged
|
|
374
|
+
elif self.revealed[row][col] == 1:
|
|
375
|
+
count = self.counts[row][col]
|
|
376
|
+
if count == 0:
|
|
377
|
+
line += " |"
|
|
378
|
+
else:
|
|
379
|
+
line += f"{count}|"
|
|
380
|
+
|
|
381
|
+
lines.append(line)
|
|
382
|
+
lines.append(" +" + "-+" * self.size)
|
|
383
|
+
|
|
384
|
+
lines.append("")
|
|
385
|
+
lines.append("Legend: . = unrevealed, F = flagged, * = mine (game over), numbers = adjacent mines")
|
|
386
|
+
|
|
387
|
+
return "\n".join(lines)
|
|
388
|
+
|
|
389
|
+
def get_rules(self) -> str:
|
|
390
|
+
"""Get the rules description for Minesweeper.
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Multi-line string describing the puzzle rules
|
|
394
|
+
"""
|
|
395
|
+
return f"""MINESWEEPER RULES:
|
|
396
|
+
- Grid contains {self.num_mines} hidden mines
|
|
397
|
+
- Reveal all non-mine cells to win
|
|
398
|
+
- Numbers show count of adjacent mines (8 directions)
|
|
399
|
+
- Flag cells you think contain mines
|
|
400
|
+
- Revealing a mine ends the game
|
|
401
|
+
- Cells with 0 adjacent mines auto-reveal neighbors
|
|
402
|
+
- Use logical deduction to find safe cells!"""
|
|
403
|
+
|
|
404
|
+
def get_commands(self) -> str:
|
|
405
|
+
"""Get the available commands for Minesweeper.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Multi-line string describing available commands
|
|
409
|
+
"""
|
|
410
|
+
return """MINESWEEPER COMMANDS:
|
|
411
|
+
reveal <row> <col> - Reveal a cell (e.g., 'reveal 3 4')
|
|
412
|
+
flag <row> <col> - Flag/unflag cell as mine
|
|
413
|
+
show - Display current grid
|
|
414
|
+
hint - Get a hint for a safe move
|
|
415
|
+
check - Check if you've won
|
|
416
|
+
solve - Show all mines (ends game)
|
|
417
|
+
menu - Return to game selection
|
|
418
|
+
quit - Exit the server"""
|
|
419
|
+
|
|
420
|
+
def get_stats(self) -> str:
|
|
421
|
+
"""Get current game statistics.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
String with game stats
|
|
425
|
+
"""
|
|
426
|
+
revealed_safe = sum(
|
|
427
|
+
1 for r in range(self.size) for c in range(self.size) if self.revealed[r][c] == 1 and not self.mines[r][c]
|
|
428
|
+
)
|
|
429
|
+
total_safe = self.size * self.size - self.num_mines
|
|
430
|
+
flags_placed = sum(1 for r in self.revealed for c in r if c == 2)
|
|
431
|
+
|
|
432
|
+
return f"Moves: {self.moves_made} | Revealed: {revealed_safe}/{total_safe} | Flags: {flags_placed}/{self.num_mines} | Seed: {self.seed}"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Configuration for Nonogram game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NonogramConfig(BaseModel):
|
|
9
|
+
"""Configuration for Nonogram game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
size: int = Field(ge=5, le=10, description="Grid size (NxN)")
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "NonogramConfig":
|
|
16
|
+
"""Create config from difficulty level."""
|
|
17
|
+
config_map = {
|
|
18
|
+
DifficultyLevel.EASY: {"size": 5},
|
|
19
|
+
DifficultyLevel.MEDIUM: {"size": 7},
|
|
20
|
+
DifficultyLevel.HARD: {"size": 10},
|
|
21
|
+
}
|
|
22
|
+
params = config_map[difficulty]
|
|
23
|
+
return cls(difficulty=difficulty, **params)
|