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,
101 """Initialize the BitBully agent.
104 opening_book (OpeningBookName | None):
105 Which opening book to load.
107 - ``"default"``: Alias for ``"12-ply-dist"``.
108 - ``"8-ply"``: 8-ply book with win/loss values.
109 - ``"12-ply"``: 12-ply book with win/loss values.
110 - ``"12-ply-dist"``: 12-ply book with win/loss *and distance* values.
111 - ``None``: Disable opening-book usage entirely.
112 tie_break (TieBreakStrategy | None):
113 Default strategy for breaking ties between equally scoring moves.
114 If ``None``, defaults to ``"center"``.
115 rng (random.Random | None):
116 Optional RNG for reproducible "random" tie-breaking.
118 TODO: Example for initialization with different books.
122 self.
tie_break = tie_break
if tie_break
is not None else "center"
123 self.
rng = rng
if rng
is not None else random.Random()
125 if opening_book
is None:
126 self.
_core = bitbully_core.BitBullyCore()
129 import bitbully_databases
as bbd
131 db_path = bbd.BitBullyDatabases.get_database_path(opening_book)
132 self.
_core = bitbully_core.BitBullyCore(Path(db_path))
135 """Return a concise string representation of the BitBully agent."""
136 return f
"BitBully(opening_book={self.opening_book_type!r}, book_loaded={self.is_book_loaded()})"
139 """Check whether an opening book is loaded.
142 bool: ``True`` if an opening book is loaded, otherwise ``False``.
146 from bitbully import BitBully
148 agent = BitBully() # per default, the 12-ply-dist book is loaded
149 assert agent.is_book_loaded() is True
153 assert agent.is_book_loaded() is False
156 return bool(self.
_core.isBookLoaded())
159 """Clear the internal transposition table."""
160 self.
_core.resetTranspositionTable()
163 """Return the number of nodes visited since the last reset.
166 int: Number of visited nodes.
170 from bitbully import BitBully, Board
174 _ = agent.score_all_moves(board)
175 print(f"Nodes visited: {agent.get_node_counter()}")
177 # Note that has to be reset manually:
178 agent.reset_node_counter()
179 assert agent.get_node_counter() == 0
182 return int(self.
_core.getNodeCounter())
185 """Reset the internal node counter.
187 See Also: [`get_node_counter`][src.bitbully.solver.BitBully.get_node_counter] for usage.
189 self.
_core.resetNodeCounter()
191 def score_move(self, board: Board, column: int, first_guess: int = 0) -> int:
192 """Evaluate a single move for the given board state.
195 board (Board): The current board state.
196 column (int): Column index (0-6) of the move to evaluate.
197 first_guess (int): Initial guess for the score (often 0).
200 int: The evaluation score of the move.
204 from bitbully import BitBully, Board
208 score = agent.score_move(board, column=3)
209 assert score == 1 # Score for the center column on an empty board
213 ValueError: If the column is outside the valid range or if the column is full.
216 - This is a wrapper around
217 [`bitbully_core.BitBullyCore.scoreMove`][src.bitbully.bitbully_core.BitBullyCore.scoreMove].
219 if not board.is_legal_move(column):
220 raise ValueError(f
"Column {column} is either full or invalid; cannot score move.")
222 return int(self.
_core.scoreMove(board.native, column, first_guess))
225 """Score all legal moves for the given board state.
228 board (Board): The current board state.
232 A dictionary of up to 7 column-value pairs, one per reachable column (0-6).
233 Higher values generally indicate better moves for the player to move. If a
234 column is full, it will not be listed in the returned dictionary.
238 from bitbully import BitBully, Board
242 scores = agent.score_all_moves(board)
243 assert scores == {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2} # Center column is best on an empty board
247 When a column is full, it is omitted from the results:
249 from bitbully import BitBully, Board
252 board = Board(6 * "3") # fill center column
253 scores = agent.score_all_moves(board)
254 assert scores == {2: 1, 4: 1, 1: 0, 5: 0, 0: -1, 6: -1} # Column 3 is full and thus omitted
257 scores = self.
_core.scoreMoves(board.native)
259 col: val
for (col, val)
in enumerate(scores)
if val > -100
261 return dict(sorted(column_values.items(), key=operator.itemgetter(1), reverse=
True))
267 tie_break: TieBreakStrategy |
None =
None,
268 rng: random.Random |
None =
None,
270 """Return the best legal move (column index) for the current player.
272 All legal moves are scored using :meth:`score_all_moves`. The move(s)
273 with the highest score are considered best, and ties are resolved
274 according to ``tie_break``.
276 Tie-breaking strategies:
277 - ``None`` (default): Use the agent's default tie-breaking strategy (`self.tie_break`).
278 - ``"center"`` (default):
279 Prefer the move closest to the center column (3). If still tied,
280 choose the smaller column index.
282 Choose the smallest column index among tied moves.
284 Choose uniformly at random among tied moves. An optional
285 ``rng`` can be provided for reproducibility.
288 board (Board): The current board state.
289 tie_break (TieBreakStrategy | None):
290 Strategy used to resolve ties between equally scoring moves.
291 rng (random.Random | None):
292 Random number generator used when ``tie_break="random"``.
293 If ``None``, the agent's (`self.rng`) RNG is used.
296 int: The selected column index (0-6).
299 ValueError: If there are no legal moves (board is full) or
300 if an unknown tie-breaking strategy is specified.
304 from bitbully import BitBully, Board
309 best_col = agent.best_move(board)
310 assert best_col == 3 # Center column is best on an empty board
315 from bitbully import BitBully, Board
319 board = Board("341") # some arbitrary position
321 assert agent.best_move(board, tie_break="center") == 3 # Several moves are tied; center is preferred
322 assert agent.best_move(board, tie_break="leftmost") == 1 # Leftmost among tied moves
323 assert agent.best_move(board, tie_break="random") in {1, 3, 4} # Random among tied moves
325 rng = random.Random(42) # use own random number generator
326 assert agent.best_move(board, tie_break="random", rng=rng) in {1, 3, 4}
340 raise ValueError(
"No legal moves available (board appears to be full).")
342 best_score = max(scores.values())
343 best_cols = [c
for c, s
in scores.items()
if s == best_score]
345 if len(best_cols) == 1:
348 if tie_break
is None:
353 if tie_break ==
"center":
355 return min(best_cols, key=
lambda c: (abs(c - 3), c))
357 if tie_break ==
"leftmost":
358 return min(best_cols)
360 if tie_break ==
"random":
362 return random.choice(best_cols)
363 return rng.choice(best_cols)
365 raise ValueError(f
"Unknown tie-breaking strategy: {tie_break!r}")
367 def negamax(self, board: Board, alpha: int = -1000, beta: int = 1000, depth: int = 0) -> int:
368 """Evaluate a position using negamax search.
371 board (Board): The board position to evaluate.
372 alpha (int): Alpha bound.
373 beta (int): Beta bound.
374 depth (int): Search depth in plies.
377 int: The evaluation score returned by the engine.
381 from bitbully import BitBully, Board
385 score = agent.negamax(board)
386 assert score == 1 # Expected score for an empty board
399 """Evaluate a position using a null-window search.
402 board (Board): The board position to evaluate.
405 int: The evaluation score.
409 from bitbully import BitBully, Board
413 score = agent.null_window(board)
414 assert score == 1 # Expected score for an empty board
417 return int(self.
_core.nullWindow(board.native))
419 def mtdf(self, board: Board, first_guess: int = 0) -> int:
420 """Evaluate a position using the MTD(f) algorithm.
423 board (Board): The board position to evaluate.
424 first_guess (int): Initial guess for the score (often 0).
427 int: The evaluation score.
431 from bitbully import BitBully, Board
435 score = agent.mtdf(board)
436 assert score == 1 # Expected score for an empty board
439 return int(self.
_core.
mtdf(board.native, first_guess=first_guess))
441 def load_book(self, book: OpeningBookName | os.PathLike[str] | str) ->
None:
442 """Load an opening book from a file path.
444 This is a thin wrapper around
445 [`bitbully_core.BitBullyCore.loadBook`][src.bitbully.bitbully_core.BitBullyCore.loadBook].
448 book (OpeningBookName | os.PathLike[str] | str):
449 Name/Identifier (see [`available_opening_books`][src.bitbully.solver.BitBully.available_opening_books])
450 or path of the opening book to load.
454 If the book identifier/path is invalid or if loading the book fails.
458 from bitbully import BitBully
459 from pathlib import Path
461 which_book = BitBully.available_opening_books()[0] # e.g., "default"
463 agent = BitBully(opening_book=None) # start without book
464 assert agent.is_book_loaded() is False
465 agent.load_book(which_book) # load "default" book
466 assert agent.is_book_loaded() is True
471 from bitbully import BitBully
472 from pathlib import Path
473 import bitbully_databases as bbd
475 which_book = BitBully.available_opening_books()[2] # e.g., "12-ply"
476 db_path = bbd.BitBullyDatabases.get_database_path(which_book)
478 agent = BitBully(opening_book=None) # start without book
479 assert agent.is_book_loaded() is False
480 agent.load_book(db_path)
481 assert agent.is_book_loaded() is True
484 self.
_core.resetBook()
485 if _is_opening_book_name(book):
486 import bitbully_databases
as bbd
488 db_path = bbd.BitBullyDatabases.get_database_path(book)
490 elif isinstance(book, (os.PathLike, str)):
491 if isinstance(book, str)
and not book.strip():
492 raise ValueError(f
"Invalid book path: {book!r}")
496 raise ValueError(f
"Invalid book identifier or path: {book!r}")
498 if not self.
_core.loadBook(db_path):
500 self.
_core.resetBook()
501 raise ValueError(f
"Failed to load opening book from path: {db_path}")
504 """Unload the currently loaded opening book (if any).
506 This resets the engine to *search-only* mode until another
507 opening book is loaded.
511 from bitbully import BitBully
513 agent = BitBully() # per default, the 12-ply-dist book is loaded
514 assert agent.is_book_loaded() is True
516 assert agent.is_book_loaded() is False
519 self.
_core.resetBook()
524 """Return the available opening book identifiers.
527 tuple[OpeningBookName, ...]:
528 All supported opening book names, including ``"default"``.
532 from bitbully import BitBully
534 books = BitBully.available_opening_books()
535 print(books) # ('default', '8-ply', '12-ply', '12-ply-dist')
539 return get_args(OpeningBookName)
int best_move(self, Board board, *, TieBreakStrategy|None tie_break=None, random.Random|None rng=None)
bool is_book_loaded(self)
None __init__(self, OpeningBookName|None opening_book="default", *, TieBreakStrategy|None tie_break=None, random.Random|None rng=None)
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 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)