1"""This module provides the Connect Four AI agent "BitBully" with opening book support."""
3from __future__
import annotations
8from pathlib
import Path
16from .
import Board, bitbully_core
18OpeningBookName: TypeAlias = Literal[
"default",
"8-ply",
"12-ply",
"12-ply-dist"]
19"""Name of the opening book used by the BitBully engine.
22- ``"default"``: Alias for ``"12-ply-dist"``.
23- ``"8-ply"``: 8-ply opening book (win/loss only).
24- ``"12-ply"``: 12-ply opening book (win/loss only).
25- ``"12-ply-dist"``: 12-ply opening book with distance-to-win information.
28TieBreakStrategy: TypeAlias = Literal[
"center",
"leftmost",
"random"]
29"""Strategy for breaking ties between equally good moves.
32- ``"center"``: Prefer moves closer to the center column.
33- ``"leftmost"``: Prefer the leftmost among the best moves.
34- ``"random"``: Choose randomly among the best moves.
38def _is_opening_book_name(x: object) -> TypeGuard[OpeningBookName]:
39 return x
in get_args(OpeningBookName)
43 """A Connect Four AI agent with optional opening book support.
46 - We have to describe the scoring scheme (range of values and their meaning).
48 This class is a high-level Python wrapper around
49 [`bitbully_core.BitBullyCore`][src.bitbully.bitbully_core.BitBullyCore].
50 It integrates the packaged *BitBully Databases* opening books and
51 operates on [`bitbully.Board`][src.bitbully.board.Board] objects.
54 - If an opening book is enabled, it is used automatically for
56 - For deeper positions or positions outside the database horizon,
57 the engine falls back to search-based evaluation.
61 from bitbully import BitBully, Board
64 board, _ = Board.random_board(n_ply=14, forbid_direct_win=True)
67 # All three search methods should agree on the score
68 score_mtdf = agent.mtdf(board)
69 score_negamax = agent.negamax(board)
70 score_null_window = agent.null_window(board)
71 assert score_negamax == score_null_window == score_mtdf
76 from bitbully import BitBully, Board
78 board = Board() # empty board
80 scores = agent.score_all_moves(board) # get scores for all moves
81 assert len(scores) == 7 # there are 7 columns
82 assert scores == {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2} # center column is best
88 {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2}
94 def __init__(self, opening_book: OpeningBookName |
None =
"default") ->
None:
95 """Initialize the BitBully agent.
98 opening_book (OpeningBookName | None):
99 Which opening book to load.
101 - ``"default"``: Alias for ``"12-ply-dist"``.
102 - ``"8-ply"``: 8-ply book with win/loss values.
103 - ``"12-ply"``: 12-ply book with win/loss values.
104 - ``"12-ply-dist"``: 12-ply book with win/loss *and distance* values.
105 - ``None``: Disable opening-book usage entirely.
107 TODO: Example for initialization with different books.
112 if opening_book
is None:
113 self.
_core = bitbully_core.BitBullyCore()
116 import bitbully_databases
as bbd
118 db_path = bbd.BitBullyDatabases.get_database_path(opening_book)
119 self.
_core = bitbully_core.BitBullyCore(Path(db_path))
122 """Return a concise string representation of the BitBully agent."""
123 return f
"BitBully(opening_book={self.opening_book_type!r}, book_loaded={self.is_book_loaded()})"
126 """Check whether an opening book is loaded.
129 bool: ``True`` if an opening book is loaded, otherwise ``False``.
133 from bitbully import BitBully
135 agent = BitBully() # per default, the 12-ply-dist book is loaded
136 assert agent.is_book_loaded() is True
140 assert agent.is_book_loaded() is False
143 return bool(self.
_core.isBookLoaded())
146 """Clear the internal transposition table."""
147 self.
_core.resetTranspositionTable()
150 """Return the number of nodes visited since the last reset.
153 int: Number of visited nodes.
157 from bitbully import BitBully, Board
161 _ = agent.score_all_moves(board)
162 print(f"Nodes visited: {agent.get_node_counter()}")
164 # Note that has to be reset manually:
165 agent.reset_node_counter()
166 assert agent.get_node_counter() == 0
169 return int(self.
_core.getNodeCounter())
172 """Reset the internal node counter.
174 See Also: [`get_node_counter`][src.bitbully.solver.BitBully.get_node_counter] for usage.
176 self.
_core.resetNodeCounter()
178 def score_move(self, board: Board, column: int, first_guess: int = 0) -> int:
179 """Evaluate a single move for the given board state.
182 board (Board): The current board state.
183 column (int): Column index (0-6) of the move to evaluate.
184 first_guess (int): Initial guess for the score (often 0).
187 int: The evaluation score of the move.
191 from bitbully import BitBully, Board
195 score = agent.score_move(board, column=3)
196 assert score == 1 # Score for the center column on an empty board
200 ValueError: If the column is outside the valid range or if the column is full.
203 - This is a wrapper around
204 [`bitbully_core.BitBullyCore.scoreMove`][src.bitbully.bitbully_core.BitBullyCore.scoreMove].
206 if not board.is_legal_move(column):
207 raise ValueError(f
"Column {column} is either full or invalid; cannot score move.")
209 return int(self.
_core.scoreMove(board.native, column, first_guess))
212 """Score all legal moves for the given board state.
215 board (Board): The current board state.
219 A dictionary of up to 7 column-value pairs, one per reachable column (0-6).
220 Higher values generally indicate better moves for the player to move. If a
221 column is full, it will not be listed in the returned dictionary.
225 from bitbully import BitBully, Board
229 scores = agent.score_all_moves(board)
230 assert scores == {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2} # Center column is best on an empty board
234 When a column is full, it is omitted from the results:
236 from bitbully import BitBully, Board
239 board = Board(6 * "3") # fill center column
240 scores = agent.score_all_moves(board)
241 assert scores == {2: 1, 4: 1, 1: 0, 5: 0, 0: -1, 6: -1} # Column 3 is full and thus omitted
244 scores = self.
_core.scoreMoves(board.native)
246 col: val
for (col, val)
in enumerate(scores)
if val > -100
248 return dict(sorted(column_values.items(), key=operator.itemgetter(1), reverse=
True))
254 tie_break: TieBreakStrategy =
"center",
255 rng: random.Random |
None =
None,
257 """Return the best legal move (column index) for the current player.
259 All legal moves are scored using :meth:`score_all_moves`. The move(s)
260 with the highest score are considered best, and ties are resolved
261 according to ``tie_break``.
263 Tie-breaking strategies:
264 - ``"center"`` (default):
265 Prefer the move closest to the center column (3). If still tied,
266 choose the smaller column index.
268 Choose the smallest column index among tied moves.
270 Choose uniformly at random among tied moves. An optional
271 ``rng`` can be provided for reproducibility.
274 board (Board): The current board state.
275 tie_break (TieBreakStrategy):
276 Strategy used to resolve ties between equally scoring moves.
277 rng (random.Random | None):
278 Random number generator used when ``tie_break="random"``.
279 If ``None``, the global RNG is used.
282 int: The selected column index (0-6).
285 ValueError: If there are no legal moves (board is full) or
286 if an unknown tie-breaking strategy is specified.
290 from bitbully import BitBully, Board
295 best_col = agent.best_move(board)
296 assert best_col == 3 # Center column is best on an empty board
301 from bitbully import BitBully, Board
305 board = Board("341") # some arbitrary position
307 assert agent.best_move(board, tie_break="center") == 3 # Several moves are tied; center is preferred
308 assert agent.best_move(board, tie_break="leftmost") == 1 # Leftmost among tied moves
309 assert agent.best_move(board, tie_break="random") in {1, 3, 4} # Random among tied moves
311 rng = random.Random(42) # use own random number generator
312 assert agent.best_move(board, tie_break="random", rng=rng) in {1, 3, 4}
326 raise ValueError(
"No legal moves available (board appears to be full).")
328 best_score = max(scores.values())
329 best_cols = [c
for c, s
in scores.items()
if s == best_score]
331 if len(best_cols) == 1:
334 if tie_break ==
"center":
336 return min(best_cols, key=
lambda c: (abs(c - 3), c))
338 if tie_break ==
"leftmost":
339 return min(best_cols)
341 if tie_break ==
"random":
343 return random.choice(best_cols)
344 return rng.choice(best_cols)
346 raise ValueError(f
"Unknown tie-breaking strategy: {tie_break!r}")
348 def negamax(self, board: Board, alpha: int = -1000, beta: int = 1000, depth: int = 0) -> int:
349 """Evaluate a position using negamax search.
352 board (Board): The board position to evaluate.
353 alpha (int): Alpha bound.
354 beta (int): Beta bound.
355 depth (int): Search depth in plies.
358 int: The evaluation score returned by the engine.
362 from bitbully import BitBully, Board
366 score = agent.negamax(board)
367 assert score == 1 # Expected score for an empty board
380 """Evaluate a position using a null-window search.
383 board (Board): The board position to evaluate.
386 int: The evaluation score.
390 from bitbully import BitBully, Board
394 score = agent.null_window(board)
395 assert score == 1 # Expected score for an empty board
398 return int(self.
_core.nullWindow(board.native))
400 def mtdf(self, board: Board, first_guess: int = 0) -> int:
401 """Evaluate a position using the MTD(f) algorithm.
404 board (Board): The board position to evaluate.
405 first_guess (int): Initial guess for the score (often 0).
408 int: The evaluation score.
412 from bitbully import BitBully, Board
416 score = agent.mtdf(board)
417 assert score == 1 # Expected score for an empty board
420 return int(self.
_core.
mtdf(board.native, first_guess=first_guess))
422 def load_book(self, book: OpeningBookName | os.PathLike[str] | str) ->
None:
423 """Load an opening book from a file path.
425 This is a thin wrapper around
426 [`bitbully_core.BitBullyCore.loadBook`][src.bitbully.bitbully_core.BitBullyCore.loadBook].
429 book (OpeningBookName | os.PathLike[str] | str):
430 Name/Identifier (see [`available_opening_books`][src.bitbully.solver.BitBully.available_opening_books])
431 or path of the opening book to load.
435 If the book identifier/path is invalid or if loading the book fails.
439 from bitbully import BitBully
440 from pathlib import Path
442 which_book = BitBully.available_opening_books()[0] # e.g., "default"
444 agent = BitBully(opening_book=None) # start without book
445 assert agent.is_book_loaded() is False
446 agent.load_book(which_book) # load "default" book
447 assert agent.is_book_loaded() is True
452 from bitbully import BitBully
453 from pathlib import Path
454 import bitbully_databases as bbd
456 which_book = BitBully.available_opening_books()[2] # e.g., "12-ply"
457 db_path = bbd.BitBullyDatabases.get_database_path(which_book)
459 agent = BitBully(opening_book=None) # start without book
460 assert agent.is_book_loaded() is False
461 agent.load_book(db_path)
462 assert agent.is_book_loaded() is True
465 self.
_core.resetBook()
466 if _is_opening_book_name(book):
467 import bitbully_databases
as bbd
469 db_path = bbd.BitBullyDatabases.get_database_path(book)
471 elif isinstance(book, (os.PathLike, str)):
472 if isinstance(book, str)
and not book.strip():
473 raise ValueError(f
"Invalid book path: {book!r}")
477 raise ValueError(f
"Invalid book identifier or path: {book!r}")
479 if not self.
_core.loadBook(db_path):
481 self.
_core.resetBook()
482 raise ValueError(f
"Failed to load opening book from path: {db_path}")
485 """Unload the currently loaded opening book (if any).
487 This resets the engine to *search-only* mode until another
488 opening book is loaded.
492 from bitbully import BitBully
494 agent = BitBully() # per default, the 12-ply-dist book is loaded
495 assert agent.is_book_loaded() is True
497 assert agent.is_book_loaded() is False
500 self.
_core.resetBook()
505 """Return the available opening book identifiers.
508 tuple[OpeningBookName, ...]:
509 All supported opening book names, including ``"default"``.
513 from bitbully import BitBully
515 books = BitBully.available_opening_books()
516 print(books) # ('default', '8-ply', '12-ply', '12-ply-dist')
520 return get_args(OpeningBookName)
bool is_book_loaded(self)
None __init__(self, OpeningBookName|None opening_book="default")
OpeningBookName|None opening_book_type
None reset_transposition_table(self)
dict[int, int] score_all_moves(self, Board board)
None load_book(self, OpeningBookName|os.PathLike[str]|str book)
int get_node_counter(self)
None reset_node_counter(self)
int best_move(self, Board board, *, TieBreakStrategy tie_break="center", random.Random|None rng=None)
int mtdf(self, Board board, int first_guess=0)
int negamax(self, Board board, int alpha=-1000, int beta=1000, int depth=0)
tuple[OpeningBookName,...] available_opening_books(cls)
int score_move(self, Board board, int column, int first_guess=0)
int null_window(self, Board board)