Skip to content

solver

This module provides the Connect Four AI agent "BitBully" with opening book support.

Classes:

Name Description
BitBully

A Connect Four AI agent with optional opening book support.

Attributes:

Name Type Description
OpeningBookName TypeAlias

Name of the opening book used by the BitBully engine.

TieBreakStrategy TypeAlias

Strategy for breaking ties between equally good moves.

OpeningBookName module-attribute

OpeningBookName: TypeAlias = Literal['default', '8-ply', '12-ply', '12-ply-dist']

Name of the opening book used by the BitBully engine.

Possible values: - "default": Alias for "12-ply-dist". - "8-ply": 8-ply opening book (win/loss only). - "12-ply": 12-ply opening book (win/loss only). - "12-ply-dist": 12-ply opening book with distance-to-win information.

TieBreakStrategy module-attribute

TieBreakStrategy: TypeAlias = Literal['center', 'leftmost', 'random']

Strategy for breaking ties between equally good moves.

Possible values: - "center": Prefer moves closer to the center column. - "leftmost": Prefer the leftmost among the best moves. - "random": Choose randomly among the best moves.

BitBully

BitBully(opening_book: OpeningBookName | None = 'default', *, tie_break: TieBreakStrategy | None = None, rng: Random | None = None)

A Connect Four AI agent with optional opening book support.

Todo: - We have to describe the scoring scheme (range of values and their meaning).

This class is a high-level Python wrapper around bitbully_core.BitBullyCore. It integrates the packaged BitBully Databases opening books and operates on bitbully.Board objects.

Notes
  • If an opening book is enabled, it is used automatically for early-game positions.
  • For deeper positions or positions outside the database horizon, the engine falls back to search-based evaluation.
Example
from bitbully import BitBully, Board

agent = BitBully()
board, _ = Board.random_board(n_ply=14, forbid_direct_win=True)
print(board)

# All three search methods should agree on the score
score_mtdf = agent.mtdf(board)
score_negamax = agent.negamax(board)
score_null_window = agent.null_window(board)
assert score_negamax == score_null_window == score_mtdf
Example
from bitbully import BitBully, Board

board = Board()  # empty board
agent = BitBully()
scores = agent.score_all_moves(board)  # get scores for all moves
assert len(scores) == 7  # there are 7 columns
assert scores == {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2}  # center column is best
print(scores)

Expected Output:

{3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2}

Parameters:

Name Type Description Default

opening_book

OpeningBookName | None

Which opening book to load.

  • "default": Alias for "12-ply-dist".
  • "8-ply": 8-ply book with win/loss values.
  • "12-ply": 12-ply book with win/loss values.
  • "12-ply-dist": 12-ply book with win/loss and distance values.
  • None: Disable opening-book usage entirely.
'default'

tie_break

TieBreakStrategy | None

Default strategy for breaking ties between equally scoring moves. If None, defaults to "center".

None

rng

Random | None

Optional RNG for reproducible "random" tie-breaking.

None

TODO: Example for initialization with different books.

Methods:

Name Description
__repr__

Return a concise string representation of the BitBully agent.

available_opening_books

Return the available opening book identifiers.

best_move

Return the best legal move (column index) for the current player.

get_node_counter

Return the number of nodes visited since the last reset.

is_book_loaded

Check whether an opening book is loaded.

load_book

Load an opening book from a file path.

mtdf

Evaluate a position using the MTD(f) algorithm.

negamax

Evaluate a position using negamax search.

null_window

Evaluate a position using a null-window search.

reset_book

Unload the currently loaded opening book (if any).

reset_node_counter

Reset the internal node counter.

reset_transposition_table

Clear the internal transposition table.

score_all_moves

Score all legal moves for the given board state.

score_move

Evaluate a single move for the given board state.

Attributes:

Name Type Description
opening_book_type OpeningBookName | None
rng
tie_break
Source code in src/bitbully/solver.py
def __init__(
    self,
    opening_book: OpeningBookName | None = "default",
    *,
    tie_break: TieBreakStrategy | None = None,
    rng: random.Random | None = None,
) -> None:
    """Initialize the BitBully agent.

    Args:
        opening_book (OpeningBookName | None):
            Which opening book to load.

            - ``"default"``: Alias for ``"12-ply-dist"``.
            - ``"8-ply"``: 8-ply book with win/loss values.
            - ``"12-ply"``: 12-ply book with win/loss values.
            - ``"12-ply-dist"``: 12-ply book with win/loss *and distance* values.
            - ``None``: Disable opening-book usage entirely.
        tie_break (TieBreakStrategy | None):
            Default strategy for breaking ties between equally scoring moves.
            If ``None``, defaults to ``"center"``.
        rng (random.Random | None):
            Optional RNG for reproducible "random" tie-breaking.

    TODO: Example for initialization with different books.

    """
    self.opening_book_type: OpeningBookName | None = opening_book
    self.tie_break = tie_break if tie_break is not None else "center"
    self.rng = rng if rng is not None else random.Random()

    if opening_book is None:
        self._core = bitbully_core.BitBullyCore()
        return

    import bitbully_databases as bbd

    db_path = bbd.BitBullyDatabases.get_database_path(opening_book)
    self._core = bitbully_core.BitBullyCore(Path(db_path))

opening_book_type instance-attribute

opening_book_type: OpeningBookName | None = opening_book

rng instance-attribute

rng = rng if rng is not None else Random()

tie_break instance-attribute

tie_break = tie_break if tie_break is not None else 'center'

__repr__

__repr__() -> str

Return a concise string representation of the BitBully agent.

Source code in src/bitbully/solver.py
def __repr__(self) -> str:
    """Return a concise string representation of the BitBully agent."""
    return f"BitBully(opening_book={self.opening_book_type!r}, book_loaded={self.is_book_loaded()})"

available_opening_books classmethod

available_opening_books() -> tuple[OpeningBookName, ...]

Return the available opening book identifiers.

Returns:

Type Description
tuple[OpeningBookName, ...]

tuple[OpeningBookName, ...]: All supported opening book names, including "default".

Example
from bitbully import BitBully

books = BitBully.available_opening_books()
print(books)  # ('default', '8-ply', '12-ply', '12-ply-dist')
Source code in src/bitbully/solver.py
@classmethod
def available_opening_books(cls) -> tuple[OpeningBookName, ...]:
    """Return the available opening book identifiers.

    Returns:
        tuple[OpeningBookName, ...]:
            All supported opening book names, including ``"default"``.

    Example:
        ```python
        from bitbully import BitBully

        books = BitBully.available_opening_books()
        print(books)  # ('default', '8-ply', '12-ply', '12-ply-dist')
        ```

    """
    return get_args(OpeningBookName)

best_move

best_move(board: Board, *, tie_break: TieBreakStrategy | None = None, rng: Random | None = None) -> int

Return the best legal move (column index) for the current player.

All legal moves are scored using :meth:score_all_moves. The move(s) with the highest score are considered best, and ties are resolved according to tie_break.

Tie-breaking strategies
  • None (default): Use the agent's default tie-breaking strategy (self.tie_break).
  • "center" (default): Prefer the move closest to the center column (3). If still tied, choose the smaller column index.
  • "leftmost": Choose the smallest column index among tied moves.
  • "random": Choose uniformly at random among tied moves. An optional rng can be provided for reproducibility.

Parameters:

Name Type Description Default

board

Board

The current board state.

required

tie_break

TieBreakStrategy | None

Strategy used to resolve ties between equally scoring moves.

None

rng

Random | None

Random number generator used when tie_break="random". If None, the agent's (self.rng) RNG is used.

None

Returns:

Name Type Description
int int

The selected column index (0-6).

Raises:

Type Description
ValueError

If there are no legal moves (board is full) or if an unknown tie-breaking strategy is specified.

Example
from bitbully import BitBully, Board
import random

agent = BitBully()
board = Board()
best_col = agent.best_move(board)
assert best_col == 3  # Center column is best on an empty board
Example

from bitbully import BitBully, Board
import random

agent = BitBully()
board = Board("341")  # some arbitrary position
print(board)
assert agent.best_move(board, tie_break="center") == 3  # Several moves are tied; center is preferred
assert agent.best_move(board, tie_break="leftmost") == 1  # Leftmost among tied moves
assert agent.best_move(board, tie_break="random") in {1, 3, 4}  # Random among tied moves

rng = random.Random(42)  # use own random number generator
assert agent.best_move(board, tie_break="random", rng=rng) in {1, 3, 4}
Expected Output:
_  _  _  _  _  _  _
_  _  _  _  _  _  _
_  _  _  _  _  _  _
_  _  _  _  _  _  _
_  _  _  _  _  _  _
_  X  _  X  O  _  _

Source code in src/bitbully/solver.py
def best_move(
    self,
    board: Board,
    *,
    tie_break: TieBreakStrategy | None = None,
    rng: random.Random | None = None,
) -> int:
    """Return the best legal move (column index) for the current player.

    All legal moves are scored using :meth:`score_all_moves`. The move(s)
    with the highest score are considered best, and ties are resolved
    according to ``tie_break``.

    Tie-breaking strategies:
        - ``None`` (default): Use the agent's default tie-breaking strategy (`self.tie_break`).
        - ``"center"`` (default):
            Prefer the move closest to the center column (3). If still tied,
            choose the smaller column index.
        - ``"leftmost"``:
            Choose the smallest column index among tied moves.
        - ``"random"``:
            Choose uniformly at random among tied moves. An optional
            ``rng`` can be provided for reproducibility.

    Args:
        board (Board): The current board state.
        tie_break (TieBreakStrategy | None):
            Strategy used to resolve ties between equally scoring moves.
        rng (random.Random | None):
            Random number generator used when ``tie_break="random"``.
            If ``None``, the agent's (`self.rng`) RNG is used.

    Returns:
        int: The selected column index (0-6).

    Raises:
        ValueError: If there are no legal moves (board is full) or
            if an unknown tie-breaking strategy is specified.

    Example:
        ```python
        from bitbully import BitBully, Board
        import random

        agent = BitBully()
        board = Board()
        best_col = agent.best_move(board)
        assert best_col == 3  # Center column is best on an empty board
        ```

    Example:
        ```python
        from bitbully import BitBully, Board
        import random

        agent = BitBully()
        board = Board("341")  # some arbitrary position
        print(board)
        assert agent.best_move(board, tie_break="center") == 3  # Several moves are tied; center is preferred
        assert agent.best_move(board, tie_break="leftmost") == 1  # Leftmost among tied moves
        assert agent.best_move(board, tie_break="random") in {1, 3, 4}  # Random among tied moves

        rng = random.Random(42)  # use own random number generator
        assert agent.best_move(board, tie_break="random", rng=rng) in {1, 3, 4}
        ```
        Expected Output:
        ```
        _  _  _  _  _  _  _
        _  _  _  _  _  _  _
        _  _  _  _  _  _  _
        _  _  _  _  _  _  _
        _  _  _  _  _  _  _
        _  X  _  X  O  _  _
        ```
    """
    scores = self.score_all_moves(board)
    if not scores:
        raise ValueError("No legal moves available (board appears to be full).")

    best_score = max(scores.values())
    best_cols = [c for c, s in scores.items() if s == best_score]

    if len(best_cols) == 1:
        return best_cols[0]

    if tie_break is None:
        tie_break = self.tie_break
    if rng is None:
        rng = self.rng

    if tie_break == "center":
        # Prefer center column (3), then smaller index for stability.
        return min(best_cols, key=lambda c: (abs(c - 3), c))

    if tie_break == "leftmost":
        return min(best_cols)

    if tie_break == "random":
        if rng is None:
            return random.choice(best_cols)
        return rng.choice(best_cols)

    raise ValueError(f"Unknown tie-breaking strategy: {tie_break!r}")

get_node_counter

get_node_counter() -> int

Return the number of nodes visited since the last reset.

Returns:

Name Type Description
int int

Number of visited nodes.

Example
from bitbully import BitBully, Board

agent = BitBully()
board = Board()
_ = agent.score_all_moves(board)
print(f"Nodes visited: {agent.get_node_counter()}")

# Note that has to be reset manually:
agent.reset_node_counter()
assert agent.get_node_counter() == 0
Source code in src/bitbully/solver.py
def get_node_counter(self) -> int:
    """Return the number of nodes visited since the last reset.

    Returns:
        int: Number of visited nodes.

    Example:
        ```python
        from bitbully import BitBully, Board

        agent = BitBully()
        board = Board()
        _ = agent.score_all_moves(board)
        print(f"Nodes visited: {agent.get_node_counter()}")

        # Note that has to be reset manually:
        agent.reset_node_counter()
        assert agent.get_node_counter() == 0
        ```
    """
    return int(self._core.getNodeCounter())

is_book_loaded

is_book_loaded() -> bool

Check whether an opening book is loaded.

Returns:

Name Type Description
bool bool

True if an opening book is loaded, otherwise False.

Example
from bitbully import BitBully

agent = BitBully()  # per default, the 12-ply-dist book is loaded
assert agent.is_book_loaded() is True

# Unload the book
agent.reset_book()
assert agent.is_book_loaded() is False
Source code in src/bitbully/solver.py
def is_book_loaded(self) -> bool:
    """Check whether an opening book is loaded.

    Returns:
        bool: ``True`` if an opening book is loaded, otherwise ``False``.

    Example:
        ```python
        from bitbully import BitBully

        agent = BitBully()  # per default, the 12-ply-dist book is loaded
        assert agent.is_book_loaded() is True

        # Unload the book
        agent.reset_book()
        assert agent.is_book_loaded() is False
        ```
    """
    return bool(self._core.isBookLoaded())

load_book

load_book(book: OpeningBookName | PathLike[str] | str) -> None

Load an opening book from a file path.

This is a thin wrapper around bitbully_core.BitBullyCore.loadBook.

Parameters:

Name Type Description Default

book

OpeningBookName | PathLike[str] | str

Name/Identifier (see available_opening_books) or path of the opening book to load.

required

Raises:

Type Description
ValueError

If the book identifier/path is invalid or if loading the book fails.

Example
from bitbully import BitBully
from pathlib import Path

which_book = BitBully.available_opening_books()[0]  # e.g., "default"

agent = BitBully(opening_book=None)  # start without book
assert agent.is_book_loaded() is False
agent.load_book(which_book)  # load "default" book
assert agent.is_book_loaded() is True
Example
from bitbully import BitBully
from pathlib import Path
import bitbully_databases as bbd

which_book = BitBully.available_opening_books()[2]  # e.g., "12-ply"
db_path = bbd.BitBullyDatabases.get_database_path(which_book)

agent = BitBully(opening_book=None)  # start without book
assert agent.is_book_loaded() is False
agent.load_book(db_path)
assert agent.is_book_loaded() is True
Source code in src/bitbully/solver.py
def load_book(self, book: OpeningBookName | os.PathLike[str] | str) -> None:
    """Load an opening book from a file path.

    This is a thin wrapper around
    [`bitbully_core.BitBullyCore.loadBook`][src.bitbully.bitbully_core.BitBullyCore.loadBook].

    Args:
        book (OpeningBookName | os.PathLike[str] | str):
            Name/Identifier (see [`available_opening_books`][src.bitbully.solver.BitBully.available_opening_books])
            or path of the opening book to load.

    Raises:
        ValueError:
            If the book identifier/path is invalid or if loading the book fails.

    Example:
        ```python
        from bitbully import BitBully
        from pathlib import Path

        which_book = BitBully.available_opening_books()[0]  # e.g., "default"

        agent = BitBully(opening_book=None)  # start without book
        assert agent.is_book_loaded() is False
        agent.load_book(which_book)  # load "default" book
        assert agent.is_book_loaded() is True
        ```

    Example:
        ```python
        from bitbully import BitBully
        from pathlib import Path
        import bitbully_databases as bbd

        which_book = BitBully.available_opening_books()[2]  # e.g., "12-ply"
        db_path = bbd.BitBullyDatabases.get_database_path(which_book)

        agent = BitBully(opening_book=None)  # start without book
        assert agent.is_book_loaded() is False
        agent.load_book(db_path)
        assert agent.is_book_loaded() is True
        ```
    """
    self._core.resetBook()
    if _is_opening_book_name(book):
        import bitbully_databases as bbd

        db_path = bbd.BitBullyDatabases.get_database_path(book)
        self.opening_book_type = book
    elif isinstance(book, (os.PathLike, str)):
        if isinstance(book, str) and not book.strip():
            raise ValueError(f"Invalid book path: {book!r}")
        db_path = Path(book)
        self.opening_book_type = None
    else:
        raise ValueError(f"Invalid book identifier or path: {book!r}")

    if not self._core.loadBook(db_path):
        self.opening_book_type = None
        self._core.resetBook()
        raise ValueError(f"Failed to load opening book from path: {db_path}")

mtdf

mtdf(board: Board, first_guess: int = 0) -> int

Evaluate a position using the MTD(f) algorithm.

Parameters:

Name Type Description Default

board

Board

The board position to evaluate.

required

first_guess

int

Initial guess for the score (often 0).

0

Returns:

Name Type Description
int int

The evaluation score.

Example
from bitbully import BitBully, Board

agent = BitBully()
board = Board()
score = agent.mtdf(board)
assert score == 1  # Expected score for an empty board
Source code in src/bitbully/solver.py
def mtdf(self, board: Board, first_guess: int = 0) -> int:
    """Evaluate a position using the MTD(f) algorithm.

    Args:
        board (Board): The board position to evaluate.
        first_guess (int): Initial guess for the score (often 0).

    Returns:
        int: The evaluation score.

    Example:
        ```python
        from bitbully import BitBully, Board

        agent = BitBully()
        board = Board()
        score = agent.mtdf(board)
        assert score == 1  # Expected score for an empty board
        ```
    """
    return int(self._core.mtdf(board.native, first_guess=first_guess))

negamax

negamax(board: Board, alpha: int = -1000, beta: int = 1000, depth: int = 0) -> int

Evaluate a position using negamax search.

Parameters:

Name Type Description Default

board

Board

The board position to evaluate.

required

alpha

int

Alpha bound.

-1000

beta

int

Beta bound.

1000

depth

int

Search depth in plies.

0

Returns:

Name Type Description
int int

The evaluation score returned by the engine.

Example
from bitbully import BitBully, Board

agent = BitBully()
board = Board()
score = agent.negamax(board)
assert score == 1  # Expected score for an empty board
Source code in src/bitbully/solver.py
def negamax(self, board: Board, alpha: int = -1000, beta: int = 1000, depth: int = 0) -> int:
    """Evaluate a position using negamax search.

    Args:
        board (Board): The board position to evaluate.
        alpha (int): Alpha bound.
        beta (int): Beta bound.
        depth (int): Search depth in plies.

    Returns:
        int: The evaluation score returned by the engine.

    Example:
        ```python
        from bitbully import BitBully, Board

        agent = BitBully()
        board = Board()
        score = agent.negamax(board)
        assert score == 1  # Expected score for an empty board
        ```
    """
    return int(
        self._core.negamax(
            board.native,
            alpha=alpha,
            beta=beta,
            depth=depth,
        )
    )

null_window

null_window(board: Board) -> int

Evaluate a position using a null-window search.

Parameters:

Name Type Description Default

board

Board

The board position to evaluate.

required

Returns:

Name Type Description
int int

The evaluation score.

Example
from bitbully import BitBully, Board

agent = BitBully()
board = Board()
score = agent.null_window(board)
assert score == 1  # Expected score for an empty board
Source code in src/bitbully/solver.py
def null_window(self, board: Board) -> int:
    """Evaluate a position using a null-window search.

    Args:
        board (Board): The board position to evaluate.

    Returns:
        int: The evaluation score.

    Example:
        ```python
        from bitbully import BitBully, Board

        agent = BitBully()
        board = Board()
        score = agent.null_window(board)
        assert score == 1  # Expected score for an empty board
        ```
    """
    return int(self._core.nullWindow(board.native))

reset_book

reset_book() -> None

Unload the currently loaded opening book (if any).

This resets the engine to search-only mode until another opening book is loaded.

Example
from bitbully import BitBully

agent = BitBully()  # per default, the 12-ply-dist book is loaded
assert agent.is_book_loaded() is True
agent.reset_book()
assert agent.is_book_loaded() is False
Source code in src/bitbully/solver.py
def reset_book(self) -> None:
    """Unload the currently loaded opening book (if any).

    This resets the engine to *search-only* mode until another
    opening book is loaded.

    Example:
        ```python
        from bitbully import BitBully

        agent = BitBully()  # per default, the 12-ply-dist book is loaded
        assert agent.is_book_loaded() is True
        agent.reset_book()
        assert agent.is_book_loaded() is False
        ```
    """
    self._core.resetBook()
    self.opening_book_type = None

reset_node_counter

reset_node_counter() -> None

Reset the internal node counter.

See Also: get_node_counter for usage.

Source code in src/bitbully/solver.py
def reset_node_counter(self) -> None:
    """Reset the internal node counter.

    See Also: [`get_node_counter`][src.bitbully.solver.BitBully.get_node_counter] for usage.
    """
    self._core.resetNodeCounter()

reset_transposition_table

reset_transposition_table() -> None

Clear the internal transposition table.

Source code in src/bitbully/solver.py
def reset_transposition_table(self) -> None:
    """Clear the internal transposition table."""
    self._core.resetTranspositionTable()

score_all_moves

score_all_moves(board: Board) -> dict[int, int]

Score all legal moves for the given board state.

Parameters:

Name Type Description Default

board

Board

The current board state.

required

Returns:

Type Description
dict[int, int]

dict[int, int]: A dictionary of up to 7 column-value pairs, one per reachable column (0-6). Higher values generally indicate better moves for the player to move. If a column is full, it will not be listed in the returned dictionary.

Example

```python from bitbully import BitBully, Board

agent = BitBully() board = Board() scores = agent.score_all_moves(board) assert scores == {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2} # Center column is best on an empty board

Example:
    When a column is full, it is omitted from the results:
    ```python
    from bitbully import BitBully, Board

    agent = BitBully()
    board = Board(6 * "3")  # fill center column
    scores = agent.score_all_moves(board)
    assert scores == {2: 1, 4: 1, 1: 0, 5: 0, 0: -1, 6: -1}  # Column 3 is full and thus omitted
Source code in src/bitbully/solver.py
def score_all_moves(self, board: Board) -> dict[int, int]:
    """Score all legal moves for the given board state.

    Args:
        board (Board): The current board state.

    Returns:
        dict[int, int]:
            A dictionary of up to 7 column-value pairs, one per reachable column (0-6).
            Higher values generally indicate better moves for the player to move. If a
            column is full, it will not be listed in the returned dictionary.

    Example:
        ```python
        from bitbully import BitBully, Board

        agent = BitBully()
        board = Board()
        scores = agent.score_all_moves(board)
        assert scores == {3: 1, 2: 0, 4: 0, 1: -1, 5: -1, 0: -2, 6: -2}  # Center column is best on an empty board
    ```

    Example:
        When a column is full, it is omitted from the results:
        ```python
        from bitbully import BitBully, Board

        agent = BitBully()
        board = Board(6 * "3")  # fill center column
        scores = agent.score_all_moves(board)
        assert scores == {2: 1, 4: 1, 1: 0, 5: 0, 0: -1, 6: -1}  # Column 3 is full and thus omitted
    ```
    """
    scores = self._core.scoreMoves(board.native)
    column_values = {
        col: val for (col, val) in enumerate(scores) if val > -100
    }  # invalid moves have score less than -100
    return dict(sorted(column_values.items(), key=operator.itemgetter(1), reverse=True))

score_move

score_move(board: Board, column: int, first_guess: int = 0) -> int

Evaluate a single move for the given board state.

Parameters:

Name Type Description Default

board

Board

The current board state.

required

column

int

Column index (0-6) of the move to evaluate.

required

first_guess

int

Initial guess for the score (often 0).

0

Returns:

Name Type Description
int int

The evaluation score of the move.

Example
from bitbully import BitBully, Board

agent = BitBully()
board = Board()
score = agent.score_move(board, column=3)
assert score == 1  # Score for the center column on an empty board

Raises:

Type Description
ValueError

If the column is outside the valid range or if the column is full.

Notes
Source code in src/bitbully/solver.py
def score_move(self, board: Board, column: int, first_guess: int = 0) -> int:
    """Evaluate a single move for the given board state.

    Args:
        board (Board): The current board state.
        column (int): Column index (0-6) of the move to evaluate.
        first_guess (int): Initial guess for the score (often 0).

    Returns:
        int: The evaluation score of the move.

    Example:
        ```python
        from bitbully import BitBully, Board

        agent = BitBully()
        board = Board()
        score = agent.score_move(board, column=3)
        assert score == 1  # Score for the center column on an empty board
        ```

    Raises:
        ValueError: If the column is outside the valid range or if the column is full.

    Notes:
        - This is a wrapper around
          [`bitbully_core.BitBullyCore.scoreMove`][src.bitbully.bitbully_core.BitBullyCore.scoreMove].
    """
    if not board.is_legal_move(column):
        raise ValueError(f"Column {column} is either full or invalid; cannot score move.")

    return int(self._core.scoreMove(board.native, column, first_guess))