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}
96 opening_book: OpeningBookName |
None =
"default",
98 tie_break: TieBreakStrategy |
None =
None,
99 rng: random.Random |
None =
None,
102 """Initialize the BitBully agent.
105 opening_book (OpeningBookName | None):
106 Which opening book to load.
108 - ``"default"``: Alias for ``"12-ply-dist"``.
109 - ``"8-ply"``: 8-ply book with win/loss values.
110 - ``"12-ply"``: 12-ply book with win/loss values.
111 - ``"12-ply-dist"``: 12-ply book with win/loss *and distance* values.
112 - ``None``: Disable opening-book usage entirely.
113 tie_break (TieBreakStrategy | None):
114 Default strategy for breaking ties between equally scoring moves.
115 If ``None``, defaults to ``"center"``.
116 rng (random.Random | None):
117 Optional RNG for reproducible "random" tie-breaking.
119 Maximum search depth in plies. When reached, a rollout using
120 non-losing moves is performed instead of continuing the search.
121 ``-1`` (default) means unlimited depth (full search).
123 TODO: Example for initialization with different books.
127 self.
tie_break = tie_break
if tie_break
is not None else "center"
128 self.
rng = rng
if rng
is not None else random.Random()
131 if opening_book
is None:
132 self.
_core = bitbully_core.BitBullyCore()
135 import bitbully_databases
as bbd
137 db_path = bbd.BitBullyDatabases.get_database_path(opening_book)
138 self.
_core = bitbully_core.BitBullyCore(Path(db_path))
141 """Return a concise string representation of the BitBully agent."""
142 return f
"BitBully(opening_book={self.opening_book_type!r}, book_loaded={self.is_book_loaded()})"
145 """Check whether an opening book is loaded.
148 bool: ``True`` if an opening book is loaded, otherwise ``False``.
152 from bitbully import BitBully
154 agent = BitBully() # per default, the 12-ply-dist book is loaded
155 assert agent.is_book_loaded() is True
159 assert agent.is_book_loaded() is False
162 return bool(self.
_core.isBookLoaded())
165 """Clear the internal transposition table."""
166 self.
_core.resetTranspositionTable()
169 """Return the number of nodes visited since the last reset.
172 int: Number of visited nodes.
176 from bitbully import BitBully, Board
180 _ = agent.score_all_moves(board)
181 print(f"Nodes visited: {agent.get_node_counter()}")
183 # Note that has to be reset manually:
184 agent.reset_node_counter()
185 assert agent.get_node_counter() == 0
188 return int(self.
_core.getNodeCounter())
191 """Reset the internal node counter.
193 See Also: [`get_node_counter`][src.bitbully.solver.BitBully.get_node_counter] for usage.
195 self.
_core.resetNodeCounter()
197 def score_move(self, board: Board, column: int, first_guess: int = 0, *, max_depth: int |
None =
None) -> int:
198 """Evaluate a single move for the given board state.
201 board (Board): The current board state.
202 column (int): Column index (0-6) of the move to evaluate.
203 first_guess (int): Initial guess for the score (often 0).
204 max_depth (int | None): Maximum search depth override.
205 ``None`` uses the instance default (``self.max_depth``).
208 int: The evaluation score of the move.
212 from bitbully import BitBully, Board
216 score = agent.score_move(board, column=3)
217 assert score == 1 # Score for the center column on an empty board
221 Score a move using depth-limited search (faster, approximate):
223 from bitbully import BitBully, Board
227 score = agent.score_move(board, column=3, max_depth=12)
228 assert isinstance(score, int)
232 ValueError: If the column is outside the valid range or if the column is full.
235 - This is a wrapper around
236 [`bitbully_core.BitBullyCore.scoreMove`][src.bitbully.bitbully_core.BitBullyCore.scoreMove].
238 if not board.is_legal_move(column):
239 raise ValueError(f
"Column {column} is either full or invalid; cannot score move.")
241 effective_max_depth = max_depth
if max_depth
is not None else self.
max_depth
242 return int(self.
_core.scoreMove(board.native, column, first_guess, max_depth=effective_max_depth))
244 def score_all_moves(self, board: Board, *, max_depth: int |
None =
None) -> dict[int, int]:
245 """Score all legal moves for the given board state.
248 board (Board): The current board state.
249 max_depth (int | None): Maximum search depth override.
250 ``None`` uses the instance default (``self.max_depth``).
254 A dictionary of up to 7 column-value pairs, one per reachable column (0-6).
255 Higher values generally indicate better moves for the player to move. If a
256 column is full, it will not be listed in the returned dictionary.
260 from bitbully import BitBully, Board
264 scores = agent.score_all_moves(board)
265 assert scores == {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2} # Center column is best on an empty board
269 When a column is full, it is omitted from the results:
271 from bitbully import BitBully, Board
274 board = Board(6 * "3") # fill center column
275 scores = agent.score_all_moves(board)
276 assert scores == {2: 1, 4: 1, 1: 0, 5: 0, 0: -1, 6: -1} # Column 3 is full and thus omitted
280 Use depth-limited search for faster (approximate) scoring:
282 from bitbully import BitBully, Board
286 scores = agent.score_all_moves(board, max_depth=12)
287 assert len(scores) == 7 # all columns playable on an empty board
290 effective_max_depth = max_depth
if max_depth
is not None else self.
max_depth
291 scores = self.
_core.scoreMoves(board.native, max_depth=effective_max_depth)
293 col: val
for (col, val)
in enumerate(scores)
if val > -100
295 return dict(sorted(column_values.items(), key=operator.itemgetter(1), reverse=
True))
301 tie_break: TieBreakStrategy |
None =
None,
302 rng: random.Random |
None =
None,
303 max_depth: int |
None =
None,
305 """Return the best legal move (column index) for the current player.
307 All legal moves are scored using :meth:`score_all_moves`. The move(s)
308 with the highest score are considered best, and ties are resolved
309 according to ``tie_break``.
311 Tie-breaking strategies:
312 - ``None`` (default): Use the agent's default tie-breaking strategy (`self.tie_break`).
313 - ``"center"`` (default):
314 Prefer the move closest to the center column (3). If still tied,
315 choose the smaller column index.
317 Choose the smallest column index among tied moves.
319 Choose uniformly at random among tied moves. An optional
320 ``rng`` can be provided for reproducibility.
323 board (Board): The current board state.
324 tie_break (TieBreakStrategy | None):
325 Strategy used to resolve ties between equally scoring moves.
326 rng (random.Random | None):
327 Random number generator used when ``tie_break="random"``.
328 If ``None``, the agent's (`self.rng`) RNG is used.
329 max_depth (int | None): Maximum search depth override.
330 ``None`` uses the instance default (``self.max_depth``).
333 int: The selected column index (0-6).
336 ValueError: If there are no legal moves (board is full) or
337 if an unknown tie-breaking strategy is specified.
341 from bitbully import BitBully, Board
346 best_col = agent.best_move(board)
347 assert best_col == 3 # Center column is best on an empty board
352 from bitbully import BitBully, Board
356 board = Board("341") # some arbitrary position
358 assert agent.best_move(board, tie_break="center") == 3 # Several moves are tied; center is preferred
359 assert agent.best_move(board, tie_break="leftmost") == 1 # Leftmost among tied moves
360 assert agent.best_move(board, tie_break="random") in {1, 3, 4} # Random among tied moves
362 rng = random.Random(42) # use own random number generator
363 assert agent.best_move(board, tie_break="random", rng=rng) in {1, 3, 4}
376 Use depth-limited search for a fast (approximate) best move:
378 from bitbully import BitBully, Board
382 col = agent.best_move(board, max_depth=12)
388 raise ValueError(
"No legal moves available (board appears to be full).")
390 best_score = max(scores.values())
391 best_cols = [c
for c, s
in scores.items()
if s == best_score]
393 if len(best_cols) == 1:
396 if tie_break
is None:
401 if tie_break ==
"center":
403 return min(best_cols, key=
lambda c: (abs(c - 3), c))
405 if tie_break ==
"leftmost":
406 return min(best_cols)
408 if tie_break ==
"random":
410 return random.choice(best_cols)
411 return rng.choice(best_cols)
413 raise ValueError(f
"Unknown tie-breaking strategy: {tie_break!r}")
416 self, board: Board, alpha: int = -1000, beta: int = 1000, depth: int = 0, *, max_depth: int |
None =
None
418 """Evaluate a position using negamax search.
421 board (Board): The board position to evaluate.
422 alpha (int): Alpha bound.
423 beta (int): Beta bound.
424 depth (int): Search depth in plies.
425 max_depth (int | None): Maximum search depth override.
426 ``None`` uses the instance default (``self.max_depth``).
429 int: The evaluation score returned by the engine.
433 from bitbully import BitBully, Board
437 score = agent.negamax(board)
438 assert score == 1 # Expected score for an empty board
442 Depth-limited negamax (rollout at depth 12):
444 from bitbully import BitBully, Board
447 board = Board("334411")
448 score = agent.negamax(board, max_depth=12)
449 assert isinstance(score, int)
452 effective_max_depth = max_depth
if max_depth
is not None else self.
max_depth
459 max_depth=effective_max_depth,
463 def null_window(self, board: Board, *, max_depth: int |
None =
None) -> int:
464 """Evaluate a position using a null-window search.
467 board (Board): The board position to evaluate.
468 max_depth (int | None): Maximum search depth override.
469 ``None`` uses the instance default (``self.max_depth``).
472 int: The evaluation score.
476 from bitbully import BitBully, Board
480 score = agent.null_window(board)
481 assert score == 1 # Expected score for an empty board
485 Depth-limited null-window search:
487 from bitbully import BitBully, Board
490 board = Board("334411")
491 score = agent.null_window(board, max_depth=12)
492 assert isinstance(score, int)
495 effective_max_depth = max_depth
if max_depth
is not None else self.
max_depth
496 return int(self.
_core.nullWindow(board.native, max_depth=effective_max_depth))
498 def mtdf(self, board: Board, first_guess: int = 0, *, max_depth: int |
None =
None) -> int:
499 """Evaluate a position using the MTD(f) algorithm.
502 board (Board): The board position to evaluate.
503 first_guess (int): Initial guess for the score (often 0).
504 max_depth (int | None): Maximum search depth override.
505 ``None`` uses the instance default (``self.max_depth``).
508 int: The evaluation score.
512 from bitbully import BitBully, Board
516 score = agent.mtdf(board)
517 assert score == 1 # Expected score for an empty board
521 Depth-limited MTD(f) (rollout at depth 12):
523 from bitbully import BitBully, Board
526 board = Board("334411")
527 score = agent.mtdf(board, max_depth=12)
528 assert isinstance(score, int)
531 effective_max_depth = max_depth
if max_depth
is not None else self.
max_depth
532 return int(self.
_core.
mtdf(board.native, first_guess=first_guess, max_depth=effective_max_depth))
536 """Convert a solver score to the number of moves until the game ends.
538 Given a score returned by the solver (e.g., from
539 :meth:`mtdf` or :meth:`score_all_moves`) and the board state,
540 returns how many moves remain until the game concludes under
544 score (int): The solver score. Positive means the current player
545 wins; negative means the current player loses; 0 means draw.
546 board (Board): The board state for which the score was computed.
549 int: Number of moves remaining until the game ends.
552 A draw uses all remaining moves:
554 from bitbully import BitBully, Board
556 board = Board() # empty board, 42 moves left
557 assert BitBully.score_to_moves_left(0, board) == 42
561 Maximum score means the current player wins on the very next move:
563 from bitbully import BitBully, Board
566 assert BitBully.score_to_moves_left(21, board) == 1
570 Combining with the solver to find how many moves until
573 from bitbully import BitBully, Board
577 score = agent.mtdf(board)
578 moves_until_end = BitBully.score_to_moves_left(score, board)
579 assert moves_until_end == 41 # yellow barely wins
582 return int(bitbully_core.BitBullyCore.scoreToMovesLeft(score, board.native))
584 def load_book(self, book: OpeningBookName | os.PathLike[str] | str) ->
None:
585 """Load an opening book from a file path.
587 This is a thin wrapper around
588 [`bitbully_core.BitBullyCore.loadBook`][src.bitbully.bitbully_core.BitBullyCore.loadBook].
591 book (OpeningBookName | os.PathLike[str] | str):
592 Name/Identifier (see [`available_opening_books`][src.bitbully.solver.BitBully.available_opening_books])
593 or path of the opening book to load.
597 If the book identifier/path is invalid or if loading the book fails.
601 from bitbully import BitBully
602 from pathlib import Path
604 which_book = BitBully.available_opening_books()[0] # e.g., "default"
606 agent = BitBully(opening_book=None) # start without book
607 assert agent.is_book_loaded() is False
608 agent.load_book(which_book) # load "default" book
609 assert agent.is_book_loaded() is True
614 from bitbully import BitBully
615 from pathlib import Path
616 import bitbully_databases as bbd
618 which_book = BitBully.available_opening_books()[2] # e.g., "12-ply"
619 db_path = bbd.BitBullyDatabases.get_database_path(which_book)
621 agent = BitBully(opening_book=None) # start without book
622 assert agent.is_book_loaded() is False
623 agent.load_book(db_path)
624 assert agent.is_book_loaded() is True
627 self.
_core.resetBook()
628 if _is_opening_book_name(book):
629 import bitbully_databases
as bbd
631 db_path = bbd.BitBullyDatabases.get_database_path(book)
633 elif isinstance(book, (os.PathLike, str)):
634 if isinstance(book, str)
and not book.strip():
635 raise ValueError(f
"Invalid book path: {book!r}")
639 raise ValueError(f
"Invalid book identifier or path: {book!r}")
641 if not self.
_core.loadBook(db_path):
643 self.
_core.resetBook()
644 raise ValueError(f
"Failed to load opening book from path: {db_path}")
647 """Unload the currently loaded opening book (if any).
649 This resets the engine to *search-only* mode until another
650 opening book is loaded.
654 from bitbully import BitBully
656 agent = BitBully() # per default, the 12-ply-dist book is loaded
657 assert agent.is_book_loaded() is True
659 assert agent.is_book_loaded() is False
662 self.
_core.resetBook()
667 """Return the available opening book identifiers.
670 tuple[OpeningBookName, ...]:
671 All supported opening book names, including ``"default"``.
675 from bitbully import BitBully
677 books = BitBully.available_opening_books()
678 print(books) # ('default', '8-ply', '12-ply', '12-ply-dist')
682 return get_args(OpeningBookName)
int score_move(self, Board board, int column, int first_guess=0, *, int|None max_depth=None)
int negamax(self, Board board, int alpha=-1000, int beta=1000, int depth=0, *, int|None max_depth=None)
bool is_book_loaded(self)
int score_to_moves_left(int score, Board board)
int null_window(self, Board board, *, int|None max_depth=None)
int best_move(self, Board board, *, TieBreakStrategy|None tie_break=None, random.Random|None rng=None, int|None max_depth=None)
OpeningBookName|None opening_book_type
None reset_transposition_table(self)
dict[int, int] score_all_moves(self, Board board, *, int|None max_depth=None)
None load_book(self, OpeningBookName|os.PathLike[str]|str book)
int get_node_counter(self)
None reset_node_counter(self)
None __init__(self, OpeningBookName|None opening_book="default", *, TieBreakStrategy|None tie_break=None, random.Random|None rng=None, int max_depth=-1)
tuple[OpeningBookName,...] available_opening_books(cls)
int mtdf(self, Board board, int first_guess=0, *, int|None max_depth=None)