pingv4 0.1.0__cp39-abi3-manylinux_2_34_x86_64.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.
pingv4/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from pingv4._core import ConnectFourBoard, CellState
2
+ from pingv4.bot import AbstractBot, RandomBot, MinimaxBot
3
+ from pingv4.game import Connect4Game, ManualPlayer, GameConfig, PlayerConfig
4
+
5
+ __all__ = [
6
+ "ConnectFourBoard",
7
+ "CellState",
8
+ "AbstractBot",
9
+ "Connect4Game",
10
+ "GameConfig",
11
+ "PlayerConfig",
12
+ "ManualPlayer",
13
+ "RandomBot",
14
+ "MinimaxBot",
15
+ ]
pingv4/_core.abi3.so ADDED
Binary file
pingv4/_core.pyi ADDED
@@ -0,0 +1,203 @@
1
+ from typing import List, Tuple, Optional
2
+ from enum import IntEnum
3
+
4
+ class CellState(IntEnum):
5
+ """
6
+ Enumeration representing the state of a cell on a Connect Four board.
7
+
8
+ Each value corresponds to the player occupying a cell.
9
+
10
+ :cvar Yellow: Indicates a cell occupied by the yellow player.
11
+ :cvar Red: Indicates a cell occupied by the red player.
12
+ """
13
+
14
+ Yellow = 0
15
+ Red = 1
16
+
17
+ class ConnectFourBoard:
18
+ def __init__(self) -> None:
19
+ """
20
+ Initializes an empty Connect Four Board.
21
+ """
22
+ ...
23
+
24
+ @property
25
+ def num_rows(self) -> int:
26
+ """
27
+ :return: The total number of rows.
28
+ :rtype: int
29
+ """
30
+ ...
31
+
32
+ @property
33
+ def num_cols(self) -> int:
34
+ """
35
+ :return: The total number of columns.
36
+ :rtype: int
37
+ """
38
+ ...
39
+
40
+ @property
41
+ def hash(self) -> int:
42
+ """
43
+ Return a hash representing the current board state.
44
+
45
+ The hash depends only on the configuration of pieces on the board
46
+ and is deterministic for a given state.
47
+
48
+ :return: A hash value for the current board state.
49
+ :rtype: int
50
+ """
51
+ ...
52
+
53
+ @property
54
+ def column_heights(self) -> List[int]:
55
+ """
56
+ Return the current heights of all columns.
57
+
58
+ Each element represents the number of pieces currently placed
59
+ in the corresponding column.
60
+
61
+ :return: A list of column heights indexed by column.
62
+ :rtype: List[int]
63
+ """
64
+ ...
65
+
66
+ @property
67
+ def is_in_progress(self) -> bool:
68
+ """
69
+ Check if the game is still in progress.
70
+
71
+ :return: True if the game is in progress (no victory or draw), False otherwise.
72
+ :rtype: bool
73
+ """
74
+ ...
75
+
76
+ @property
77
+ def current_player(self) -> Optional[CellState]:
78
+ """
79
+ Get the current player whose turn it is.
80
+
81
+ :return: The current player if game is in progress, None otherwise.
82
+ :rtype: Optional[CellState]
83
+ """
84
+ ...
85
+
86
+ @property
87
+ def is_victory(self) -> bool:
88
+ """
89
+ Check if the game has ended in a victory.
90
+
91
+ :return: True if a player has won, False otherwise.
92
+ :rtype: bool
93
+ """
94
+ ...
95
+
96
+ @property
97
+ def winner(self) -> Optional[CellState]:
98
+ """
99
+ Get the winning player.
100
+
101
+ :return: The winning player if there is a victory, None otherwise.
102
+ :rtype: Optional[CellState]
103
+ """
104
+ ...
105
+
106
+ @property
107
+ def is_draw(self) -> bool:
108
+ """
109
+ Check if the game has ended in a draw.
110
+
111
+ :return: True if the board is full with no winner, False otherwise.
112
+ :rtype: bool
113
+ """
114
+ ...
115
+
116
+ @property
117
+ def cell_states(self) -> List[List[Optional[CellState]]]:
118
+ """
119
+ Return the state of all cells on the board.
120
+
121
+ .. warning::
122
+ Cell states are stored in **column-major** order. Access as
123
+ ``cell_states[col_idx][row_idx]`` where ``col_idx`` is the column
124
+ index (0-6) and ``row_idx`` is the row index (0-5, bottom to top).
125
+
126
+ :return: A nested list of cell states indexed by [column][row].
127
+ :rtype: List[List[Optional[CellState]]]
128
+ """
129
+ ...
130
+
131
+ def get_valid_moves(self) -> List[int]:
132
+ """
133
+ Return all moves that can be made given the current game state.
134
+
135
+ Returns empty list if game is in draw/victory state.
136
+
137
+ :return: A list of column indexes that are valid moves
138
+ :rtype: List[int]
139
+ """
140
+ ...
141
+
142
+ def make_move(self, col_idx: int) -> "ConnectFourBoard":
143
+ """
144
+ Make a move in the specified column.
145
+
146
+ Returns a new board with the move applied. The current board
147
+ remains unchanged (immutable).
148
+
149
+ :param col_idx: The zero-indexed column to drop the piece into.
150
+ :type col_idx: int
151
+ :return: A new board with the move applied.
152
+ :rtype: ConnectFourBoard
153
+ :raises ValueError: If column index is out of bounds, column is full,
154
+ or game is not in progress (victory/draw).
155
+ """
156
+ ...
157
+
158
+ def __getitem__(self, idx: Tuple[int, int]) -> Optional[CellState]:
159
+ """
160
+ Return the state of a specific cell on the board.
161
+
162
+ .. warning::
163
+ Access is **column-major**: the first index is the column, the second
164
+ is the row. Use ``board[col_idx, row_idx]`` where ``col_idx`` is the
165
+ column (0-6) and ``row_idx`` is the row (0-5, bottom to top).
166
+
167
+ :param idx: A zero-indexed ``(col_idx, row_idx)`` tuple identifying the cell.
168
+ :type idx: Tuple[int, int]
169
+ :return: The cell state if occupied, or None if the cell is empty.
170
+ :rtype: Optional[CellState]
171
+ """
172
+ ...
173
+
174
+ def __hash__(self) -> int:
175
+ """
176
+ Return the hash value of the board.
177
+
178
+ Enables the board to be used in hash-based collections.
179
+
180
+ :return: The hash value of the board.
181
+ :rtype: int
182
+ """
183
+ ...
184
+
185
+ def __eq__(self, other: object) -> bool:
186
+ """
187
+ Compare this board with another object for equality.
188
+
189
+ Boards are equal if they are the same type and have identical
190
+ piece configurations.
191
+
192
+ :param other: The object to compare against.
193
+ :return: True if equal, False otherwise.
194
+ :rtype: bool
195
+ """
196
+ ...
197
+
198
+ def __str__(self) -> str:
199
+ """
200
+ :return: The string representation of the board
201
+ :rtype: str
202
+ """
203
+ ...
pingv4/bot/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from pingv4.bot.base import AbstractBot, RandomBot
2
+ from pingv4.bot.minimax import MinimaxBot
3
+
4
+ __all__ = [
5
+ "AbstractBot",
6
+ "RandomBot",
7
+ "MinimaxBot",
8
+ ]
pingv4/bot/base.py ADDED
@@ -0,0 +1,77 @@
1
+ import random
2
+ from abc import ABC, abstractmethod
3
+ from typing import Final
4
+
5
+ from pingv4._core import CellState, ConnectFourBoard
6
+
7
+
8
+ class AbstractBot(ABC):
9
+ """
10
+ Abstract base class for all ConnectFour bots
11
+ """
12
+
13
+ def __init__(self, player: CellState) -> None:
14
+ """
15
+ :param player: The CellState (Red or Yellow) this bot is playing as
16
+ :type player: CellState
17
+ """
18
+ self.player: Final[CellState] = player
19
+
20
+ @property
21
+ @abstractmethod
22
+ def strategy_name(self) -> str:
23
+ """
24
+ Human-readable name of the bot's strategy.
25
+ """
26
+ raise NotImplementedError
27
+
28
+ @property
29
+ @abstractmethod
30
+ def author_name(self) -> str:
31
+ """
32
+ Full name of the bot's author.
33
+ """
34
+ raise NotImplementedError
35
+
36
+ @property
37
+ @abstractmethod
38
+ def author_netid(self) -> str:
39
+ """
40
+ NetID (or equivalent identifier) of the bot's author.
41
+ """
42
+ raise NotImplementedError
43
+
44
+ @abstractmethod
45
+ def get_move(self, board: ConnectFourBoard) -> int:
46
+ """
47
+ Decide which column to place your next coin in
48
+
49
+ :param board: The current ConnectFour Board state
50
+ :type board: ConnectFourBoard
51
+ :return: A valid column index (0 <= i <= 6)
52
+ :rtype: int
53
+ """
54
+ raise NotImplementedError
55
+
56
+
57
+ class RandomBot(AbstractBot):
58
+ """A simple bot that plays random valid moves."""
59
+
60
+ def __init__(self, player: CellState) -> None:
61
+ super().__init__(player)
62
+
63
+ @property
64
+ def strategy_name(self) -> str:
65
+ return "RandomBot"
66
+
67
+ @property
68
+ def author_name(self) -> str:
69
+ return "SDIYBT"
70
+
71
+ @property
72
+ def author_netid(self) -> str:
73
+ return "SDIYBT"
74
+
75
+ def get_move(self, board: ConnectFourBoard) -> int:
76
+ valid_moves = board.get_valid_moves()
77
+ return random.choice(valid_moves)
pingv4/bot/minimax.py ADDED
@@ -0,0 +1,334 @@
1
+ from typing import Dict, Optional, Tuple
2
+
3
+ from pingv4._core import CellState, ConnectFourBoard
4
+ from pingv4.bot.base import AbstractBot
5
+
6
+
7
+ # Transposition table entry types
8
+ EXACT = 0
9
+ LOWERBOUND = 1
10
+ UPPERBOUND = 2
11
+
12
+
13
+ class MinimaxBot(AbstractBot):
14
+ """
15
+ A competent Connect Four bot using Minimax with Alpha-Beta pruning.
16
+
17
+ Features:
18
+ - Alpha-beta pruning for efficient search
19
+ - Transposition table using board.hash for caching
20
+ - Move ordering (center-first) for better pruning
21
+ - Iterative deepening for time management
22
+ - Sophisticated positional evaluation
23
+ """
24
+
25
+ def __init__(self, player: CellState, max_depth: int = 6) -> None:
26
+ super().__init__(player)
27
+ self.max_depth = max_depth
28
+ self.opponent = CellState.Yellow if player == CellState.Red else CellState.Red
29
+
30
+ # Transposition table: hash -> (depth, score, flag, best_move)
31
+ self._tt: Dict[int, Tuple[int, float, int, Optional[int]]] = {}
32
+
33
+ # Center-preference move ordering (center columns searched first)
34
+ self._move_order = [3, 2, 4, 1, 5, 0, 6]
35
+
36
+ # Precomputed weights for positional evaluation
37
+ # Center columns are more valuable
38
+ self._col_weights = [1, 2, 3, 4, 3, 2, 1]
39
+
40
+ # Window scoring weights
41
+ self._window_scores = {
42
+ 4: 100000, # Four in a row (win)
43
+ 3: 50, # Three with open space
44
+ 2: 5, # Two with open spaces
45
+ }
46
+
47
+ @property
48
+ def strategy_name(self) -> str:
49
+ return f"MinimaxBot (depth={self.max_depth})"
50
+
51
+ @property
52
+ def author_name(self) -> str:
53
+ return "Pingv4"
54
+
55
+ @property
56
+ def author_netid(self) -> str:
57
+ return "pingv4"
58
+
59
+ def get_move(self, board: ConnectFourBoard) -> int:
60
+ """Select the best move using iterative deepening minimax."""
61
+ valid_moves = board.get_valid_moves()
62
+
63
+ if not valid_moves:
64
+ raise ValueError("No valid moves available")
65
+
66
+ # Check for immediate winning move
67
+ for move in valid_moves:
68
+ next_board = board.make_move(move)
69
+ if next_board.is_victory and next_board.winner == self.player:
70
+ return move
71
+
72
+ # Check for blocking opponent's winning move
73
+ for move in valid_moves:
74
+ next_board = board.make_move(move)
75
+ # Simulate opponent playing in same column on current board
76
+ # by checking if after our move, opponent would win there
77
+ test_board = self._simulate_opponent_move(board, move)
78
+ if (
79
+ test_board
80
+ and test_board.is_victory
81
+ and test_board.winner == self.opponent
82
+ ):
83
+ return move
84
+
85
+ # Iterative deepening
86
+ best_move = valid_moves[0]
87
+ for depth in range(1, self.max_depth + 1):
88
+ move, _ = self._search_root(board, depth)
89
+ if move is not None:
90
+ best_move = move
91
+
92
+ return best_move
93
+
94
+ def _simulate_opponent_move(
95
+ self, board: ConnectFourBoard, col: int
96
+ ) -> Optional[ConnectFourBoard]:
97
+ """Simulate what would happen if opponent played in this column."""
98
+ # Check if the column has room and simulate opponent move
99
+ if board.column_heights[col] < board.num_rows:
100
+ # Create a hypothetical board where it's opponent's turn
101
+ # This is a simplification - we just check if this column would be dangerous
102
+ try:
103
+ # Make our move, then imagine opponent there
104
+ return board.make_move(col)
105
+ except ValueError:
106
+ return None
107
+ return None
108
+
109
+ def _search_root(
110
+ self, board: ConnectFourBoard, depth: int
111
+ ) -> Tuple[Optional[int], float]:
112
+ """Root-level search with move ordering from transposition table."""
113
+ valid_moves = board.get_valid_moves()
114
+ if not valid_moves:
115
+ return None, 0.0
116
+
117
+ # Order moves: TT best move first, then center-preference
118
+ ordered_moves = self._order_moves(valid_moves, board.hash)
119
+
120
+ best_move = ordered_moves[0]
121
+ best_score = float("-inf")
122
+ alpha = float("-inf")
123
+ beta = float("inf")
124
+
125
+ for move in ordered_moves:
126
+ next_board = board.make_move(move)
127
+ score = -self._negamax(next_board, depth - 1, -beta, -alpha, -1)
128
+
129
+ if score > best_score:
130
+ best_score = score
131
+ best_move = move
132
+
133
+ alpha = max(alpha, score)
134
+
135
+ return best_move, best_score
136
+
137
+ def _negamax(
138
+ self,
139
+ board: ConnectFourBoard,
140
+ depth: int,
141
+ alpha: float,
142
+ beta: float,
143
+ color: int,
144
+ ) -> float:
145
+ """
146
+ Negamax with alpha-beta pruning and transposition table.
147
+
148
+ Args:
149
+ board: Current board state
150
+ depth: Remaining search depth
151
+ alpha: Alpha bound
152
+ beta: Beta bound
153
+ color: 1 if maximizing for current player, -1 otherwise
154
+
155
+ Returns:
156
+ Evaluation score from the perspective of the current player
157
+ """
158
+ alpha_orig = alpha
159
+ board_hash = board.hash
160
+
161
+ # Transposition table lookup
162
+ if board_hash in self._tt:
163
+ tt_depth, tt_score, tt_flag, tt_move = self._tt[board_hash]
164
+ if tt_depth >= depth:
165
+ if tt_flag == EXACT:
166
+ return tt_score
167
+ elif tt_flag == LOWERBOUND:
168
+ alpha = max(alpha, tt_score)
169
+ elif tt_flag == UPPERBOUND:
170
+ beta = min(beta, tt_score)
171
+
172
+ if alpha >= beta:
173
+ return tt_score
174
+
175
+ # Terminal state check
176
+ if not board.is_in_progress:
177
+ if board.is_victory:
178
+ # Large negative score (opponent won on their move)
179
+ return -100000 - depth # Prefer faster wins
180
+ else:
181
+ return 0 # Draw
182
+
183
+ # Depth limit - evaluate position
184
+ if depth <= 0:
185
+ return color * self._evaluate(board)
186
+
187
+ valid_moves = board.get_valid_moves()
188
+ ordered_moves = self._order_moves(valid_moves, board_hash)
189
+
190
+ best_score = float("-inf")
191
+ best_move = ordered_moves[0] if ordered_moves else None
192
+
193
+ for move in ordered_moves:
194
+ next_board = board.make_move(move)
195
+ score = -self._negamax(next_board, depth - 1, -beta, -alpha, -color)
196
+
197
+ if score > best_score:
198
+ best_score = score
199
+ best_move = move
200
+
201
+ alpha = max(alpha, score)
202
+ if alpha >= beta:
203
+ break # Beta cutoff
204
+
205
+ # Store in transposition table
206
+ if best_score <= alpha_orig:
207
+ flag = UPPERBOUND
208
+ elif best_score >= beta:
209
+ flag = LOWERBOUND
210
+ else:
211
+ flag = EXACT
212
+
213
+ self._tt[board_hash] = (depth, best_score, flag, best_move)
214
+
215
+ return best_score
216
+
217
+ def _order_moves(self, moves: list, board_hash: int) -> list:
218
+ """Order moves for better alpha-beta pruning."""
219
+ # Check if we have a best move from transposition table
220
+ tt_best = None
221
+ if board_hash in self._tt:
222
+ tt_best = self._tt[board_hash][3]
223
+
224
+ # Sort by: TT best move first, then center preference
225
+ def move_priority(move: int) -> int:
226
+ if move == tt_best:
227
+ return -100 # Highest priority
228
+ try:
229
+ return -self._move_order.index(move)
230
+ except ValueError:
231
+ return 0
232
+
233
+ return sorted(moves, key=move_priority)
234
+
235
+ def _evaluate(self, board: ConnectFourBoard) -> float:
236
+ """
237
+ Evaluate the board position from the current player's perspective.
238
+
239
+ Considers:
240
+ - Winning/losing positions
241
+ - Threats (three in a row with open space)
242
+ - Center control
243
+ - Piece connectivity
244
+ """
245
+ if board.is_victory:
246
+ if board.winner == self.player:
247
+ return 100000
248
+ else:
249
+ return -100000
250
+
251
+ if board.is_draw:
252
+ return 0
253
+
254
+ score = 0.0
255
+
256
+ # Evaluate all windows of 4
257
+ score += self._evaluate_windows(board)
258
+
259
+ # Center column control bonus
260
+ score += self._evaluate_center(board)
261
+
262
+ return score
263
+
264
+ def _evaluate_windows(self, board: ConnectFourBoard) -> float:
265
+ """Evaluate all possible winning windows."""
266
+ score = 0.0
267
+ num_rows = board.num_rows
268
+ num_cols = board.num_cols
269
+
270
+ # Horizontal windows
271
+ for row in range(num_rows):
272
+ for col in range(num_cols - 3):
273
+ window = [board[col + i, row] for i in range(4)]
274
+ score += self._score_window(window)
275
+
276
+ # Vertical windows
277
+ for col in range(num_cols):
278
+ for row in range(num_rows - 3):
279
+ window = [board[col, row + i] for i in range(4)]
280
+ score += self._score_window(window)
281
+
282
+ # Positive diagonal (bottom-left to top-right)
283
+ for row in range(num_rows - 3):
284
+ for col in range(num_cols - 3):
285
+ window = [board[col + i, row + i] for i in range(4)]
286
+ score += self._score_window(window)
287
+
288
+ # Negative diagonal (top-left to bottom-right)
289
+ for row in range(3, num_rows):
290
+ for col in range(num_cols - 3):
291
+ window = [board[col + i, row - i] for i in range(4)]
292
+ score += self._score_window(window)
293
+
294
+ return score
295
+
296
+ def _score_window(self, window: list) -> float:
297
+ """Score a window of 4 cells."""
298
+ player_count = sum(1 for cell in window if cell == self.player)
299
+ opponent_count = sum(1 for cell in window if cell == self.opponent)
300
+ empty_count = sum(1 for cell in window if cell is None)
301
+
302
+ # Can't score if both players have pieces in window
303
+ if player_count > 0 and opponent_count > 0:
304
+ return 0
305
+
306
+ if player_count == 4:
307
+ return self._window_scores[4]
308
+ elif player_count == 3 and empty_count == 1:
309
+ return self._window_scores[3]
310
+ elif player_count == 2 and empty_count == 2:
311
+ return self._window_scores[2]
312
+ elif opponent_count == 4:
313
+ return -self._window_scores[4]
314
+ elif opponent_count == 3 and empty_count == 1:
315
+ return -self._window_scores[3] * 1.1 # Slightly prioritize blocking
316
+ elif opponent_count == 2 and empty_count == 2:
317
+ return -self._window_scores[2]
318
+
319
+ return 0
320
+
321
+ def _evaluate_center(self, board: ConnectFourBoard) -> float:
322
+ """Bonus for controlling the center column."""
323
+ center_col = board.num_cols // 2
324
+ center_count = 0
325
+ opponent_center = 0
326
+
327
+ for row in range(board.num_rows):
328
+ cell = board[center_col, row]
329
+ if cell == self.player:
330
+ center_count += 1
331
+ elif cell == self.opponent:
332
+ opponent_center += 1
333
+
334
+ return (center_count - opponent_center) * 3