reznum-minesweeper 0.2.2__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.
@@ -0,0 +1,5 @@
1
+ """Minesweeper CLI - A modern, colorful terminal Minesweeper game."""
2
+
3
+ __version__ = "0.2.2"
4
+ __author__ = "Made with ❤️ by ItsReZNuM"
5
+ __all__ = ["__version__", "__author__"]
@@ -0,0 +1,7 @@
1
+ """Main entrypoint when invoked as a module: python -m minesweeper_cli."""
2
+
3
+ import sys
4
+ from minesweeper_cli.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,225 @@
1
+ """Minesweeper board engine and gameplay mechanics."""
2
+
3
+ import random
4
+ from enum import Enum, auto
5
+ from typing import List, Tuple, Set, Optional
6
+ from minesweeper_cli.cell import Cell
7
+
8
+
9
+ class GameStatus(Enum):
10
+ """Lifecycle states of a Minesweeper game."""
11
+ READY = auto() # Board created, first move not yet taken
12
+ PLAYING = auto() # Game active, timer ticking
13
+ WON = auto() # All non-mine cells revealed
14
+ LOST = auto() # Mine triggered
15
+
16
+
17
+ class Board:
18
+ """Core Minesweeper game board."""
19
+
20
+ def __init__(self, width: int, height: int, num_mines: int) -> None:
21
+ if width < 1 or height < 1:
22
+ raise ValueError("Board dimensions must be at least 1x1")
23
+ total_cells = width * height
24
+ if num_mines < 0 or num_mines >= total_cells:
25
+ raise ValueError(f"Mines ({num_mines}) must be between 0 and {total_cells - 1}")
26
+
27
+ self.width = width
28
+ self.height = height
29
+ self.num_mines = num_mines
30
+ self.grid: List[List[Cell]] = [[Cell() for _ in range(width)] for _ in range(height)]
31
+ self.status = GameStatus.READY
32
+ self.first_move = True
33
+ self.revealed_count = 0
34
+ self.flagged_count = 0
35
+ self.exploded_pos: Optional[Tuple[int, int]] = None
36
+
37
+ def in_bounds(self, x: int, y: int) -> bool:
38
+ """Check if grid coordinates (x=column, y=row) are within board bounds."""
39
+ return 0 <= x < self.width and 0 <= y < self.height
40
+
41
+ def get_cell(self, x: int, y: int) -> Cell:
42
+ """Get cell at coordinates (x, y)."""
43
+ if not self.in_bounds(x, y):
44
+ raise IndexError(f"Coordinates ({x}, {y}) out of bounds")
45
+ return self.grid[y][x]
46
+
47
+ def get_neighbors(self, x: int, y: int) -> List[Tuple[int, int]]:
48
+ """Return list of (nx, ny) coordinates for all valid adjacent neighbors."""
49
+ neighbors = []
50
+ for dy in (-1, 0, 1):
51
+ for dx in (-1, 0, 1):
52
+ if dx == 0 and dy == 0:
53
+ continue
54
+ nx, ny = x + dx, y + dy
55
+ if self.in_bounds(nx, ny):
56
+ neighbors.append((nx, ny))
57
+ return neighbors
58
+
59
+ def place_mines(self, safe_x: int, safe_y: int) -> None:
60
+ """Generate mines with first-move protection ensuring safe_x, safe_y is safe."""
61
+ # Prefer guaranteeing safe_x, safe_y and its 8 neighbors have no mines if space permits
62
+ forbidden: Set[Tuple[int, int]] = {(safe_x, safe_y)}
63
+ neighbors = self.get_neighbors(safe_x, safe_y)
64
+ total_cells = self.width * self.height
65
+
66
+ if total_cells - (len(neighbors) + 1) >= self.num_mines:
67
+ forbidden.update(neighbors)
68
+
69
+ candidates = [
70
+ (x, y)
71
+ for y in range(self.height)
72
+ for x in range(self.width)
73
+ if (x, y) not in forbidden
74
+ ]
75
+
76
+ # If candidates are fewer than required mines (dense custom board), fall back to only safe cell
77
+ if len(candidates) < self.num_mines:
78
+ candidates = [
79
+ (x, y)
80
+ for y in range(self.height)
81
+ for x in range(self.width)
82
+ if (x, y) != (safe_x, safe_y)
83
+ ]
84
+
85
+ mine_coords = set(random.sample(candidates, self.num_mines))
86
+
87
+ for x, y in mine_coords:
88
+ self.grid[y][x].is_mine = True
89
+
90
+ # Calculate neighbor mine counts for each cell
91
+ for y in range(self.height):
92
+ for x in range(self.width):
93
+ if self.grid[y][x].is_mine:
94
+ continue
95
+ count = sum(1 for nx, ny in self.get_neighbors(x, y) if self.grid[ny][nx].is_mine)
96
+ self.grid[y][x].adjacent_mines = count
97
+
98
+ def reveal(self, x: int, y: int) -> bool:
99
+ """Reveal cell at (x, y). Returns True if action succeeded."""
100
+ if not self.in_bounds(x, y) or self.status in (GameStatus.WON, GameStatus.LOST):
101
+ return False
102
+
103
+ cell = self.grid[y][x]
104
+ if not cell.can_reveal:
105
+ return False
106
+
107
+ # First move protection: generate mine layout ensuring first reveal is safe
108
+ if self.first_move:
109
+ self.place_mines(x, y)
110
+ self.first_move = False
111
+ self.status = GameStatus.PLAYING
112
+
113
+ if cell.is_mine:
114
+ cell.is_exploded = True
115
+ self.exploded_pos = (x, y)
116
+ self.status = GameStatus.LOST
117
+ self._reveal_all_mines()
118
+ return True
119
+
120
+ # Flood fill reveal
121
+ self._flood_reveal(x, y)
122
+ self._check_win_condition()
123
+ return True
124
+
125
+ def _flood_reveal(self, start_x: int, start_y: int) -> None:
126
+ """Breadth-first search expanding zero-neighbor cells and boundary numbers."""
127
+ queue: List[Tuple[int, int]] = [(start_x, start_y)]
128
+ visited: Set[Tuple[int, int]] = set()
129
+
130
+ while queue:
131
+ cx, cy = queue.pop(0)
132
+ if (cx, cy) in visited:
133
+ continue
134
+ visited.add((cx, cy))
135
+
136
+ cell = self.grid[cy][cx]
137
+ if cell.is_mine:
138
+ continue
139
+
140
+ if not cell.is_revealed and not cell.is_flagged:
141
+ cell.is_revealed = True
142
+ self.revealed_count += 1
143
+
144
+ if cell.adjacent_mines == 0:
145
+ for nx, ny in self.get_neighbors(cx, cy):
146
+ if (nx, ny) not in visited:
147
+ neighbor_cell = self.grid[ny][nx]
148
+ if not neighbor_cell.is_revealed and not neighbor_cell.is_flagged and not neighbor_cell.is_mine:
149
+ queue.append((nx, ny))
150
+
151
+ def chord_reveal(self, x: int, y: int) -> bool:
152
+ """Reveal surrounding unflagged cells if flagged neighbor count matches cell number."""
153
+ if not self.in_bounds(x, y) or self.status != GameStatus.PLAYING:
154
+ return False
155
+
156
+ cell = self.grid[y][x]
157
+ if not cell.is_revealed or cell.adjacent_mines == 0:
158
+ return False
159
+
160
+ neighbors = self.get_neighbors(x, y)
161
+ flagged_neighbors = sum(1 for nx, ny in neighbors if self.grid[ny][nx].is_flagged)
162
+
163
+ if flagged_neighbors != cell.adjacent_mines:
164
+ return False
165
+
166
+ any_revealed = False
167
+ for nx, ny in neighbors:
168
+ n_cell = self.grid[ny][nx]
169
+ if not n_cell.is_revealed and not n_cell.is_flagged:
170
+ if n_cell.is_mine:
171
+ n_cell.is_exploded = True
172
+ self.exploded_pos = (nx, ny)
173
+ self.status = GameStatus.LOST
174
+ self._reveal_all_mines()
175
+ return True
176
+ self._flood_reveal(nx, ny)
177
+ any_revealed = True
178
+
179
+ self._check_win_condition()
180
+ return any_revealed
181
+
182
+ def toggle_flag(self, x: int, y: int) -> bool:
183
+ """Toggle flag marker on hidden cell (x, y)."""
184
+ if not self.in_bounds(x, y) or self.status in (GameStatus.WON, GameStatus.LOST):
185
+ return False
186
+
187
+ cell = self.grid[y][x]
188
+ if cell.is_revealed:
189
+ return False
190
+
191
+ if cell.is_flagged:
192
+ cell.is_flagged = False
193
+ self.flagged_count -= 1
194
+ else:
195
+ cell.is_flagged = True
196
+ self.flagged_count += 1
197
+
198
+ return True
199
+
200
+ def _check_win_condition(self) -> None:
201
+ """Check if all non-mine cells have been opened."""
202
+ total_cells = self.width * self.height
203
+ non_mine_cells = total_cells - self.num_mines
204
+ if self.revealed_count == non_mine_cells and self.status != GameStatus.LOST:
205
+ self.status = GameStatus.WON
206
+ # Auto-flag remaining hidden mines for polished display
207
+ for y in range(self.height):
208
+ for x in range(self.width):
209
+ c = self.grid[y][x]
210
+ if c.is_mine and not c.is_flagged:
211
+ c.is_flagged = True
212
+ self.flagged_count += 1
213
+
214
+ def _reveal_all_mines(self) -> None:
215
+ """Expose mine positions on game loss."""
216
+ for y in range(self.height):
217
+ for x in range(self.width):
218
+ c = self.grid[y][x]
219
+ if c.is_mine:
220
+ c.is_revealed = True
221
+
222
+ @property
223
+ def remaining_flags(self) -> int:
224
+ """Count of flags remaining relative to total mines."""
225
+ return self.num_mines - self.flagged_count
@@ -0,0 +1,30 @@
1
+ """Minesweeper individual cell representation."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class Cell:
8
+ """Represents a single tile on the Minesweeper board."""
9
+ is_mine: bool = False
10
+ is_revealed: bool = False
11
+ is_flagged: bool = False
12
+ is_exploded: bool = False
13
+ adjacent_mines: int = 0
14
+
15
+ @property
16
+ def is_hidden(self) -> bool:
17
+ """Return True if the cell has not yet been opened."""
18
+ return not self.is_revealed
19
+
20
+ @property
21
+ def can_reveal(self) -> bool:
22
+ """A cell can only be revealed if it is hidden and not flagged."""
23
+ return not self.is_revealed and not self.is_flagged
24
+
25
+ def toggle_flag(self) -> bool:
26
+ """Toggle flag status if hidden. Returns True if now flagged, False otherwise."""
27
+ if self.is_revealed:
28
+ return False
29
+ self.is_flagged = not self.is_flagged
30
+ return self.is_flagged
minesweeper_cli/cli.py ADDED
@@ -0,0 +1,84 @@
1
+ """Command-line interface entrypoint and argument parser."""
2
+
3
+ import argparse
4
+ import sys
5
+ from typing import List, Optional
6
+
7
+ from minesweeper_cli import __version__
8
+ from minesweeper_cli.config import APP_NAME, DIFFICULTIES
9
+ from minesweeper_cli.menu import MenuController
10
+ from minesweeper_cli.records import RecordsManager
11
+ from minesweeper_cli.settings import SettingsManager
12
+ from minesweeper_cli.themes import THEMES
13
+
14
+
15
+ def create_parser() -> argparse.ArgumentParser:
16
+ """Build command-line arguments parser."""
17
+ parser = argparse.ArgumentParser(
18
+ prog="minesweeper",
19
+ description=f"{APP_NAME} - A modern, colorful terminal Minesweeper game written in Python.",
20
+ formatter_class=argparse.RawDescriptionHelpFormatter,
21
+ )
22
+
23
+ parser.add_argument(
24
+ "-v", "--version",
25
+ action="version",
26
+ version=f"{APP_NAME} {__version__}",
27
+ help="Show version information and exit.",
28
+ )
29
+
30
+ parser.add_argument(
31
+ "-d", "--difficulty",
32
+ choices=["easy", "medium", "hard", "expert"],
33
+ type=str.lower,
34
+ help="Directly launch game with specified difficulty preset.",
35
+ )
36
+
37
+ parser.add_argument(
38
+ "-c", "--custom",
39
+ action="store_true",
40
+ help="Directly open custom board setup dialog.",
41
+ )
42
+
43
+ parser.add_argument(
44
+ "-t", "--theme",
45
+ choices=list(THEMES.keys()),
46
+ type=str.lower,
47
+ help="Select visual color theme (classic, matrix, ocean, dracula, cyberpunk, nord, monochrome).",
48
+ )
49
+
50
+ return parser
51
+
52
+
53
+ def main(argv: Optional[List[str]] = None) -> int:
54
+ """CLI execution entrypoint."""
55
+ parser = create_parser()
56
+ args = parser.parse_args(argv)
57
+
58
+ settings_mgr = SettingsManager()
59
+ records_mgr = RecordsManager()
60
+
61
+ if args.theme:
62
+ settings_mgr.settings.theme = args.theme
63
+ settings_mgr.save()
64
+
65
+ menu = MenuController(settings_manager=settings_mgr, records_manager=records_mgr)
66
+
67
+ try:
68
+ if args.difficulty:
69
+ chosen = DIFFICULTIES[args.difficulty]
70
+ menu.launch_game(chosen.width, chosen.height, chosen.mines, chosen.name)
71
+ return 0
72
+ elif args.custom:
73
+ menu.menu_custom_game()
74
+ return 0
75
+ else:
76
+ menu.run()
77
+ return 0
78
+ except KeyboardInterrupt:
79
+ sys.stdout.write("\nThanks for playing Minesweeper CLI!\n")
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())
@@ -0,0 +1,62 @@
1
+ """Global configuration, difficulty presets, and branding metadata."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Dict
5
+
6
+ APP_NAME = "Minesweeper CLI"
7
+ APP_SLUG = "minesweeper-cli"
8
+ AUTHOR = "Made with ❤️ by ItsReZNuM"
9
+ GITHUB_PROFILE = "https://github.com/ItsReZNuM"
10
+ GITHUB_REPO = "https://github.com/ItsReZNuM/Minesweeper-CLI"
11
+ TELEGRAM = "https://t.me/ItsReZNuM"
12
+ INSTAGRAM = "https://instagram.com/rez.num"
13
+ TERMINAL_DEFAULT = "Classic CMD"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DifficultyConfig:
18
+ """Predefined difficulty configuration."""
19
+ name: str
20
+ width: int
21
+ height: int
22
+ mines: int
23
+ description: str
24
+
25
+
26
+ DIFFICULTIES: Dict[str, DifficultyConfig] = {
27
+ "easy": DifficultyConfig(
28
+ name="Easy",
29
+ width=9,
30
+ height=9,
31
+ mines=10,
32
+ description="9x9 board with 10 mines (Beginner friendly)",
33
+ ),
34
+ "medium": DifficultyConfig(
35
+ name="Medium",
36
+ width=16,
37
+ height=16,
38
+ mines=40,
39
+ description="16x16 board with 40 mines (Standard challenge)",
40
+ ),
41
+ "hard": DifficultyConfig(
42
+ name="Hard",
43
+ width=30,
44
+ height=16,
45
+ mines=99,
46
+ description="30x16 board with 99 mines (Classic advanced)",
47
+ ),
48
+ "expert": DifficultyConfig(
49
+ name="Expert",
50
+ width=30,
51
+ height=24,
52
+ mines=150,
53
+ description="30x24 board with 150 mines (Master challenge)",
54
+ ),
55
+ }
56
+
57
+ # Custom mode boundary constraints
58
+ MIN_BOARD_WIDTH = 4
59
+ MAX_BOARD_WIDTH = 60
60
+ MIN_BOARD_HEIGHT = 4
61
+ MAX_BOARD_HEIGHT = 40
62
+ MIN_MINES_COUNT = 1
@@ -0,0 +1,97 @@
1
+ """Game controls, key mapping definitions, and conflict validation."""
2
+
3
+ from typing import Dict, List, Optional, Tuple
4
+
5
+ ACTION_UP = "up"
6
+ ACTION_DOWN = "down"
7
+ ACTION_LEFT = "left"
8
+ ACTION_RIGHT = "right"
9
+ ACTION_REVEAL = "reveal"
10
+ ACTION_FLAG = "flag"
11
+ ACTION_CHORD = "chord"
12
+ ACTION_RESTART = "restart"
13
+ ACTION_QUIT = "quit"
14
+
15
+ ALL_ACTIONS = [
16
+ ACTION_UP,
17
+ ACTION_DOWN,
18
+ ACTION_LEFT,
19
+ ACTION_RIGHT,
20
+ ACTION_REVEAL,
21
+ ACTION_FLAG,
22
+ ACTION_CHORD,
23
+ ACTION_RESTART,
24
+ ACTION_QUIT,
25
+ ]
26
+
27
+ ACTION_LABELS = {
28
+ ACTION_UP: "Move Up",
29
+ ACTION_DOWN: "Move Down",
30
+ ACTION_LEFT: "Move Left",
31
+ ACTION_RIGHT: "Move Right",
32
+ ACTION_REVEAL: "Reveal Cell",
33
+ ACTION_FLAG: "Place / Remove Flag",
34
+ ACTION_CHORD: "Chord Reveal (Quick Open)",
35
+ ACTION_RESTART: "Restart Game",
36
+ ACTION_QUIT: "Quit to Menu",
37
+ }
38
+
39
+ DEFAULT_KEY_BINDINGS: Dict[str, List[str]] = {
40
+ ACTION_UP: ["w", "up"],
41
+ ACTION_DOWN: ["s", "down"],
42
+ ACTION_LEFT: ["a", "left"],
43
+ ACTION_RIGHT: ["d", "right"],
44
+ ACTION_REVEAL: ["enter", "space"],
45
+ ACTION_FLAG: ["f"],
46
+ ACTION_CHORD: ["c"],
47
+ ACTION_RESTART: ["r"],
48
+ ACTION_QUIT: ["q", "escape"],
49
+ }
50
+
51
+
52
+ def normalize_key(key: str) -> str:
53
+ """Normalize key string representation for comparison."""
54
+ if not key:
55
+ return ""
56
+ mapping = {
57
+ "\r": "enter",
58
+ "\n": "enter",
59
+ " ": "space",
60
+ "\x1b": "escape",
61
+ "esc": "escape",
62
+ }
63
+ if key in mapping:
64
+ return mapping[key]
65
+ cleaned = key.strip().lower()
66
+ return mapping.get(cleaned, cleaned)
67
+
68
+
69
+ def validate_key_bindings(bindings: Dict[str, List[str]]) -> Tuple[bool, Optional[str]]:
70
+ """Verify that every action has at least one key and no key is assigned to multiple actions."""
71
+ for action in ALL_ACTIONS:
72
+ if action not in bindings or not bindings[action]:
73
+ return False, f"Action '{ACTION_LABELS.get(action, action)}' must have at least one key assigned."
74
+
75
+ seen_keys: Dict[str, str] = {}
76
+ for action, keys in bindings.items():
77
+ for k in keys:
78
+ norm = normalize_key(k)
79
+ if not norm:
80
+ continue
81
+ if norm in seen_keys and seen_keys[norm] != action:
82
+ existing_action = ACTION_LABELS.get(seen_keys[norm], seen_keys[norm])
83
+ new_action = ACTION_LABELS.get(action, action)
84
+ return False, f"Key '{norm}' conflicts between '{existing_action}' and '{new_action}'."
85
+ seen_keys[norm] = action
86
+
87
+ return True, None
88
+
89
+
90
+ def get_action_for_key(key: str, bindings: Dict[str, List[str]]) -> Optional[str]:
91
+ """Resolve normalized key string into an action name."""
92
+ normalized = normalize_key(key)
93
+ for action, keys in bindings.items():
94
+ for k in keys:
95
+ if normalize_key(k) == normalized:
96
+ return action
97
+ return None