Skip to content

BitBully API Reference

Main

BitBully: A Connect Four AI agent with opening book support.

BitBully

A Connect Four AI agent with opening book support.

Source code in src/bitbully/bitbully.py
class BitBully:
    """A Connect Four AI agent with opening book support."""

    def __init__(self, opening_book: Literal["default", "8-ply", "12-ply", "12-ply-dist"] | None = "default") -> None:
        """Initializes the BitBully agent with the specified opening book.

        Args:
            opening_book (Literal["default", "8-ply", "12-ply", "12-ply-dist"] | None):
                The type of opening book to use. Options are:
                - "default": Uses the default 12-ply distance-based opening book.
                - "8-ply": Uses an 8-ply opening book.
                - "12-ply": Uses a 12-ply opening book.
                - "12-ply-dist": Uses a 12-ply distance-based opening book.
                - None: No opening book will be used.
        """
        self.opening_book_type = opening_book

        db_path: Traversable | str | None = None
        if opening_book == "default":
            db_path = importlib.resources.files("bitbully").joinpath("assets/book_12ply_distances.dat")
        elif opening_book == "8-ply":
            db_path = importlib.resources.files("bitbully").joinpath("assets/book_8ply.dat")
        elif opening_book == "12-ply":
            db_path = importlib.resources.files("bitbully").joinpath("assets/book_12ply.dat")
        elif opening_book == "12-ply-dist":
            db_path = importlib.resources.files("bitbully").joinpath("assets/book_12ply_distances.dat")

        if db_path:
            self.bitbully_agent = bitbully_core.BitBullyCore(pathlib.Path(str(db_path)))
        else:
            self.bitbully_agent = bitbully_core.BitBullyCore()

    def score_next_moves(self, board: Board) -> list[int]:
        """Scores all possible moves for the given board state.

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

        Returns:
            list[int]: A list of scores for each column (0-6).
        """
        return self.bitbully_agent.scoreMoves(board._board)

__init__(opening_book='default')

Initializes the BitBully agent with the specified opening book.

Parameters:

Name Type Description Default
opening_book Literal['default', '8-ply', '12-ply', '12-ply-dist'] | None

The type of opening book to use. Options are: - "default": Uses the default 12-ply distance-based opening book. - "8-ply": Uses an 8-ply opening book. - "12-ply": Uses a 12-ply opening book. - "12-ply-dist": Uses a 12-ply distance-based opening book. - None: No opening book will be used.

'default'
Source code in src/bitbully/bitbully.py
def __init__(self, opening_book: Literal["default", "8-ply", "12-ply", "12-ply-dist"] | None = "default") -> None:
    """Initializes the BitBully agent with the specified opening book.

    Args:
        opening_book (Literal["default", "8-ply", "12-ply", "12-ply-dist"] | None):
            The type of opening book to use. Options are:
            - "default": Uses the default 12-ply distance-based opening book.
            - "8-ply": Uses an 8-ply opening book.
            - "12-ply": Uses a 12-ply opening book.
            - "12-ply-dist": Uses a 12-ply distance-based opening book.
            - None: No opening book will be used.
    """
    self.opening_book_type = opening_book

    db_path: Traversable | str | None = None
    if opening_book == "default":
        db_path = importlib.resources.files("bitbully").joinpath("assets/book_12ply_distances.dat")
    elif opening_book == "8-ply":
        db_path = importlib.resources.files("bitbully").joinpath("assets/book_8ply.dat")
    elif opening_book == "12-ply":
        db_path = importlib.resources.files("bitbully").joinpath("assets/book_12ply.dat")
    elif opening_book == "12-ply-dist":
        db_path = importlib.resources.files("bitbully").joinpath("assets/book_12ply_distances.dat")

    if db_path:
        self.bitbully_agent = bitbully_core.BitBullyCore(pathlib.Path(str(db_path)))
    else:
        self.bitbully_agent = bitbully_core.BitBullyCore()

score_next_moves(board)

Scores all possible moves for the given board state.

Parameters:

Name Type Description Default
board Board

The current board state.

required

Returns:

Type Description
list[int]

list[int]: A list of scores for each column (0-6).

Source code in src/bitbully/bitbully.py
def score_next_moves(self, board: Board) -> list[int]:
    """Scores all possible moves for the given board state.

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

    Returns:
        list[int]: A list of scores for each column (0-6).
    """
    return self.bitbully_agent.scoreMoves(board._board)

This module defines the Board class for managing the state of a Connect Four game.

Board

Represents the state of a Connect Four board. Mostly a thin wrapper around BoardCore.

Source code in src/bitbully/board.py
class Board:
    """Represents the state of a Connect Four board. Mostly a thin wrapper around BoardCore."""

    def __init__(self, board: Sequence[Sequence[int]] | Sequence[int] | str | None = None) -> None:
        """Initializes a Board instance.

        Args:
            board (Sequence[Sequence[int]] | Sequence[int] | str | None):
                Optional initial board state. Accepts:
                - 2D array (list, tuple, numpy-array) with shape 7x6 or 6x7
                - 1D sequence of ints: a move sequence of columns (e.g., [0, 0, 2, 2, 3, 3])
                - String: A move sequence of columns as string (e.g., "002233...")
                - None for an empty board

        Example:
            Here we have an example:
            ``` py
            board = Board()  # Empty board

            board = Board([[0] * 6 for _ in range(7)])  # empty board as 2D list
            ```

            It is possible to write some text inbetween the examples.

        Example:
            And here we have another example:
            ``` py
            board = Board("002233...")  # String
            ```
        """
        # TODO: The constructor does still not accept all types here. Fix that.
        self._board = bitbully_core.BoardCore()

    def __eq__(self, value: object) -> bool:
        """Checks equality between two Board instances.

        Args:
            value (object): The other Board instance to compare against.

        Returns:
            bool: True if both boards are equal, False otherwise.

        Raises:
            NotImplementedError: If the other value is not a Board instance.
        """
        if not isinstance(value, Board):
            raise NotImplementedError("Can only compare with another Board instance.")
        return bool(self._board == value._board)

    def __ne__(self, value: object) -> bool:
        """Checks inequality between two Board instances.

        Args:
            value (object): The other Board instance to compare against.

        Returns:
            bool: True if both boards are not equal, False otherwise.
        """
        return not self.__eq__(value)

    def __repr__(self) -> str:
        """Returns a string representation of the Board instance."""
        return f"Board({self._board})"

    def all_positions(self, up_to_n_ply: int, exactly_n: bool) -> list[Board]:
        """Finds all positions on the board up to a certain ply.

        Args:
            up_to_n_ply (int): The maximum ply depth to search.
            exactly_n (bool): If True, only returns positions at exactly N ply.

        Returns:
            list[Board]: A list of Board instances representing all positions.
        """
        # TODO: Implement this method properly. Need to convert BoardCore instances to Board.
        # return self._board.all_positions(up_to_n_ply, exactly_n)
        return [Board()]

    def can_win_next(self, move: int | None = None) -> bool:
        """Checks if the current player can win in the next move.

        Args:
            move (int | None): Optional column to check for an immediate win. If None, checks all columns.

        Returns:
            bool: True if the current player can win next, False otherwise.
        """
        if move is None:
            return self._board.canWin()
        return bool(self._board.canWin(move))

    def copy(self) -> Board:
        """Creates a copy of the current Board instance.

        Returns:
            Board: A new Board instance that is a copy of the current one.
        """
        new_board = Board()
        new_board._board = self._board.copy()
        return new_board

    def count_tokens(self) -> int:
        """Counts the total number of tokens on the board.

        Returns:
            int: The total number of tokens.
        """
        return self._board.countTokens()

    def get_legal_moves(self) -> list[int]:
        """Returns a list of legal moves (columns) that can be played.

        Returns:
            list[int]: A list of column indices (0-6) where a move can be played.

        Raises:
            NotImplementedError: If the method is not implemented yet.
        """
        raise NotImplementedError("get_legal_moves is not implemented yet.")

    def has_win(self) -> bool:
        """Checks if the current player has a winning position.

        Returns:
            bool: True if the current player has a winning position (4-in-a-row), False otherwise.
        """
        return self._board.hasWin()

    def __hash__(self) -> int:
        """Returns a hash of the Board instance for use in hash-based collections.

        Returns:
            int: The hash value of the Board instance.
        """
        return self._board.hash()

    def is_legal_move(self, move: int) -> bool:
        """Checks if a move (column) is legal.

        Args:
            move (int): The column index (0-6) to check.

        Returns:
            bool: True if the move is legal, False otherwise.
        """
        return self._board.isLegalMove(move)

    def mirror(self) -> Board:
        """Returns a new Board instance that is the mirror image of the current board.

        Returns:
            Board: A new Board instance that is the mirror image.
        """
        new_board = Board()
        new_board._board = self._board.mirror()
        return new_board

    def moves_left(self) -> int:
        """Returns the number of moves left until the board is full.

        Returns:
            int: The number of moves left (0-42).
        """
        return self._board.movesLeft()

    def play_move(self, move: int) -> bool:
        """Plays a move (column) for the current player.

        Args:
            move (int): The column index (0-6) where the token should be placed.

        Returns:
            bool: True if the move was played successfully, False if the move was illegal.
        """
        return self._board.playMove(move)

    def play_move_on_copy(self, move: int) -> Board | None:
        """Plays a move on a copy of the current board and returns the new board.

        Args:
            move (int): The column index (0-6) where the token should be placed.

        Returns:
            Board | None: A new Board instance with the move played, or None if the move was illegal.
        """
        new_board = self.copy()
        if new_board.play_move(move):
            return new_board
        return None

    def set_board(self, board: list[list[int]] | list[int]) -> bool:
        """Sets (overrides) the board to a specific state.

        Args:
            board (list[list[int]] | list[int]):
                The new board state. Accepts:
                - 2D array (list, tuple, numpy-array) with shape 7x6 or 6x7
                - 1D sequence of ints: a move sequence of columns (e.g., [0, 0, 2, 2, 3, 3])
                - String: A move sequence of columns as string (e.g., "002233...")

        Returns:
            bool: True if the board was set successfully, False otherwise.
        """
        # TODO: also allow other types for `board`, e.g., numpy arrays and convert to a list of lists
        if isinstance(board, list):
            return self._board.setBoard(board)
        return False

    def to_array(self) -> list[list[int]]:
        """Returns the board state as a 2D array (list of lists).

        Returns:
            list[list[int]]: A 2D list representing the board state.
        """
        return self._board.toArray()

    def to_string(self) -> str:
        """Returns a string representation of the board to print on the command line.

        Returns:
            str: A string representing the board (e.g., "002233...").
        """
        return self._board.toString()

    def uid(self) -> int:
        """Returns a unique identifier for the current board state.

        Returns:
            int: A unique integer identifier for the board state.
        """
        return self._board.uid()

    @staticmethod
    def random_board(n_ply: int, forbid_direct_win: bool) -> None:
        """Generates a random board state by playing a specified number of random moves.

        Args:
            n_ply (int): The number of random moves to play on the board.
            forbid_direct_win (bool): If True, the board will have a state that would result in an immediate win.
        """
        bitbully_core.BoardCore.randomBoard(n_ply, forbid_direct_win)

    def reset(self) -> None:
        """Resets the board to an empty state."""
        self._board = bitbully_core.BoardCore()

__eq__(value)

Checks equality between two Board instances.

Parameters:

Name Type Description Default
value object

The other Board instance to compare against.

required

Returns:

Name Type Description
bool bool

True if both boards are equal, False otherwise.

Raises:

Type Description
NotImplementedError

If the other value is not a Board instance.

Source code in src/bitbully/board.py
def __eq__(self, value: object) -> bool:
    """Checks equality between two Board instances.

    Args:
        value (object): The other Board instance to compare against.

    Returns:
        bool: True if both boards are equal, False otherwise.

    Raises:
        NotImplementedError: If the other value is not a Board instance.
    """
    if not isinstance(value, Board):
        raise NotImplementedError("Can only compare with another Board instance.")
    return bool(self._board == value._board)

__hash__()

Returns a hash of the Board instance for use in hash-based collections.

Returns:

Name Type Description
int int

The hash value of the Board instance.

Source code in src/bitbully/board.py
def __hash__(self) -> int:
    """Returns a hash of the Board instance for use in hash-based collections.

    Returns:
        int: The hash value of the Board instance.
    """
    return self._board.hash()

__init__(board=None)

Initializes a Board instance.

Parameters:

Name Type Description Default
board Sequence[Sequence[int]] | Sequence[int] | str | None

Optional initial board state. Accepts: - 2D array (list, tuple, numpy-array) with shape 7x6 or 6x7 - 1D sequence of ints: a move sequence of columns (e.g., [0, 0, 2, 2, 3, 3]) - String: A move sequence of columns as string (e.g., "002233...") - None for an empty board

None
Example

Here we have an example:

board = Board()  # Empty board

board = Board([[0] * 6 for _ in range(7)])  # empty board as 2D list

It is possible to write some text inbetween the examples.

Example

And here we have another example:

board = Board("002233...")  # String

Source code in src/bitbully/board.py
def __init__(self, board: Sequence[Sequence[int]] | Sequence[int] | str | None = None) -> None:
    """Initializes a Board instance.

    Args:
        board (Sequence[Sequence[int]] | Sequence[int] | str | None):
            Optional initial board state. Accepts:
            - 2D array (list, tuple, numpy-array) with shape 7x6 or 6x7
            - 1D sequence of ints: a move sequence of columns (e.g., [0, 0, 2, 2, 3, 3])
            - String: A move sequence of columns as string (e.g., "002233...")
            - None for an empty board

    Example:
        Here we have an example:
        ``` py
        board = Board()  # Empty board

        board = Board([[0] * 6 for _ in range(7)])  # empty board as 2D list
        ```

        It is possible to write some text inbetween the examples.

    Example:
        And here we have another example:
        ``` py
        board = Board("002233...")  # String
        ```
    """
    # TODO: The constructor does still not accept all types here. Fix that.
    self._board = bitbully_core.BoardCore()

__ne__(value)

Checks inequality between two Board instances.

Parameters:

Name Type Description Default
value object

The other Board instance to compare against.

required

Returns:

Name Type Description
bool bool

True if both boards are not equal, False otherwise.

Source code in src/bitbully/board.py
def __ne__(self, value: object) -> bool:
    """Checks inequality between two Board instances.

    Args:
        value (object): The other Board instance to compare against.

    Returns:
        bool: True if both boards are not equal, False otherwise.
    """
    return not self.__eq__(value)

__repr__()

Returns a string representation of the Board instance.

Source code in src/bitbully/board.py
def __repr__(self) -> str:
    """Returns a string representation of the Board instance."""
    return f"Board({self._board})"

all_positions(up_to_n_ply, exactly_n)

Finds all positions on the board up to a certain ply.

Parameters:

Name Type Description Default
up_to_n_ply int

The maximum ply depth to search.

required
exactly_n bool

If True, only returns positions at exactly N ply.

required

Returns:

Type Description
list[Board]

list[Board]: A list of Board instances representing all positions.

Source code in src/bitbully/board.py
def all_positions(self, up_to_n_ply: int, exactly_n: bool) -> list[Board]:
    """Finds all positions on the board up to a certain ply.

    Args:
        up_to_n_ply (int): The maximum ply depth to search.
        exactly_n (bool): If True, only returns positions at exactly N ply.

    Returns:
        list[Board]: A list of Board instances representing all positions.
    """
    # TODO: Implement this method properly. Need to convert BoardCore instances to Board.
    # return self._board.all_positions(up_to_n_ply, exactly_n)
    return [Board()]

can_win_next(move=None)

Checks if the current player can win in the next move.

Parameters:

Name Type Description Default
move int | None

Optional column to check for an immediate win. If None, checks all columns.

None

Returns:

Name Type Description
bool bool

True if the current player can win next, False otherwise.

Source code in src/bitbully/board.py
def can_win_next(self, move: int | None = None) -> bool:
    """Checks if the current player can win in the next move.

    Args:
        move (int | None): Optional column to check for an immediate win. If None, checks all columns.

    Returns:
        bool: True if the current player can win next, False otherwise.
    """
    if move is None:
        return self._board.canWin()
    return bool(self._board.canWin(move))

copy()

Creates a copy of the current Board instance.

Returns:

Name Type Description
Board Board

A new Board instance that is a copy of the current one.

Source code in src/bitbully/board.py
def copy(self) -> Board:
    """Creates a copy of the current Board instance.

    Returns:
        Board: A new Board instance that is a copy of the current one.
    """
    new_board = Board()
    new_board._board = self._board.copy()
    return new_board

count_tokens()

Counts the total number of tokens on the board.

Returns:

Name Type Description
int int

The total number of tokens.

Source code in src/bitbully/board.py
def count_tokens(self) -> int:
    """Counts the total number of tokens on the board.

    Returns:
        int: The total number of tokens.
    """
    return self._board.countTokens()

Returns a list of legal moves (columns) that can be played.

Returns:

Type Description
list[int]

list[int]: A list of column indices (0-6) where a move can be played.

Raises:

Type Description
NotImplementedError

If the method is not implemented yet.

Source code in src/bitbully/board.py
def get_legal_moves(self) -> list[int]:
    """Returns a list of legal moves (columns) that can be played.

    Returns:
        list[int]: A list of column indices (0-6) where a move can be played.

    Raises:
        NotImplementedError: If the method is not implemented yet.
    """
    raise NotImplementedError("get_legal_moves is not implemented yet.")

has_win()

Checks if the current player has a winning position.

Returns:

Name Type Description
bool bool

True if the current player has a winning position (4-in-a-row), False otherwise.

Source code in src/bitbully/board.py
def has_win(self) -> bool:
    """Checks if the current player has a winning position.

    Returns:
        bool: True if the current player has a winning position (4-in-a-row), False otherwise.
    """
    return self._board.hasWin()

Checks if a move (column) is legal.

Parameters:

Name Type Description Default
move int

The column index (0-6) to check.

required

Returns:

Name Type Description
bool bool

True if the move is legal, False otherwise.

Source code in src/bitbully/board.py
def is_legal_move(self, move: int) -> bool:
    """Checks if a move (column) is legal.

    Args:
        move (int): The column index (0-6) to check.

    Returns:
        bool: True if the move is legal, False otherwise.
    """
    return self._board.isLegalMove(move)

mirror()

Returns a new Board instance that is the mirror image of the current board.

Returns:

Name Type Description
Board Board

A new Board instance that is the mirror image.

Source code in src/bitbully/board.py
def mirror(self) -> Board:
    """Returns a new Board instance that is the mirror image of the current board.

    Returns:
        Board: A new Board instance that is the mirror image.
    """
    new_board = Board()
    new_board._board = self._board.mirror()
    return new_board

moves_left()

Returns the number of moves left until the board is full.

Returns:

Name Type Description
int int

The number of moves left (0-42).

Source code in src/bitbully/board.py
def moves_left(self) -> int:
    """Returns the number of moves left until the board is full.

    Returns:
        int: The number of moves left (0-42).
    """
    return self._board.movesLeft()

play_move(move)

Plays a move (column) for the current player.

Parameters:

Name Type Description Default
move int

The column index (0-6) where the token should be placed.

required

Returns:

Name Type Description
bool bool

True if the move was played successfully, False if the move was illegal.

Source code in src/bitbully/board.py
def play_move(self, move: int) -> bool:
    """Plays a move (column) for the current player.

    Args:
        move (int): The column index (0-6) where the token should be placed.

    Returns:
        bool: True if the move was played successfully, False if the move was illegal.
    """
    return self._board.playMove(move)

play_move_on_copy(move)

Plays a move on a copy of the current board and returns the new board.

Parameters:

Name Type Description Default
move int

The column index (0-6) where the token should be placed.

required

Returns:

Type Description
Board | None

Board | None: A new Board instance with the move played, or None if the move was illegal.

Source code in src/bitbully/board.py
def play_move_on_copy(self, move: int) -> Board | None:
    """Plays a move on a copy of the current board and returns the new board.

    Args:
        move (int): The column index (0-6) where the token should be placed.

    Returns:
        Board | None: A new Board instance with the move played, or None if the move was illegal.
    """
    new_board = self.copy()
    if new_board.play_move(move):
        return new_board
    return None

random_board(n_ply, forbid_direct_win) staticmethod

Generates a random board state by playing a specified number of random moves.

Parameters:

Name Type Description Default
n_ply int

The number of random moves to play on the board.

required
forbid_direct_win bool

If True, the board will have a state that would result in an immediate win.

required
Source code in src/bitbully/board.py
@staticmethod
def random_board(n_ply: int, forbid_direct_win: bool) -> None:
    """Generates a random board state by playing a specified number of random moves.

    Args:
        n_ply (int): The number of random moves to play on the board.
        forbid_direct_win (bool): If True, the board will have a state that would result in an immediate win.
    """
    bitbully_core.BoardCore.randomBoard(n_ply, forbid_direct_win)

reset()

Resets the board to an empty state.

Source code in src/bitbully/board.py
def reset(self) -> None:
    """Resets the board to an empty state."""
    self._board = bitbully_core.BoardCore()

set_board(board)

Sets (overrides) the board to a specific state.

Parameters:

Name Type Description Default
board list[list[int]] | list[int]

The new board state. Accepts: - 2D array (list, tuple, numpy-array) with shape 7x6 or 6x7 - 1D sequence of ints: a move sequence of columns (e.g., [0, 0, 2, 2, 3, 3]) - String: A move sequence of columns as string (e.g., "002233...")

required

Returns:

Name Type Description
bool bool

True if the board was set successfully, False otherwise.

Source code in src/bitbully/board.py
def set_board(self, board: list[list[int]] | list[int]) -> bool:
    """Sets (overrides) the board to a specific state.

    Args:
        board (list[list[int]] | list[int]):
            The new board state. Accepts:
            - 2D array (list, tuple, numpy-array) with shape 7x6 or 6x7
            - 1D sequence of ints: a move sequence of columns (e.g., [0, 0, 2, 2, 3, 3])
            - String: A move sequence of columns as string (e.g., "002233...")

    Returns:
        bool: True if the board was set successfully, False otherwise.
    """
    # TODO: also allow other types for `board`, e.g., numpy arrays and convert to a list of lists
    if isinstance(board, list):
        return self._board.setBoard(board)
    return False

to_array()

Returns the board state as a 2D array (list of lists).

Returns:

Type Description
list[list[int]]

list[list[int]]: A 2D list representing the board state.

Source code in src/bitbully/board.py
def to_array(self) -> list[list[int]]:
    """Returns the board state as a 2D array (list of lists).

    Returns:
        list[list[int]]: A 2D list representing the board state.
    """
    return self._board.toArray()

to_string()

Returns a string representation of the board to print on the command line.

Returns:

Name Type Description
str str

A string representing the board (e.g., "002233...").

Source code in src/bitbully/board.py
def to_string(self) -> str:
    """Returns a string representation of the board to print on the command line.

    Returns:
        str: A string representing the board (e.g., "002233...").
    """
    return self._board.toString()

uid()

Returns a unique identifier for the current board state.

Returns:

Name Type Description
int int

A unique integer identifier for the board state.

Source code in src/bitbully/board.py
def uid(self) -> int:
    """Returns a unique identifier for the current board state.

    Returns:
        int: A unique integer identifier for the board state.
    """
    return self._board.uid()

GUI module for the BitBully Connect-4 interactive widget.

GuiC4

A class which allows to create an interactive Connect-4 widget.

GuiC4 is an interactive Connect-4 graphical user interface (GUI) implemented using Matplotlib, IPython widgets, and a backend agent from the BitBully engine. It provides the following main features:

  • Interactive Game Board: Presents a dynamic 6-row by 7-column Connect-4 board with clickable board cells.
  • Matplotlib Integration: Utilizes Matplotlib figures to render high-quality game visuals directly within Jupyter notebook environments.
  • User Interaction: Captures and processes mouse clicks and button events, enabling intuitive gameplay via either direct board interaction or button controls.
  • Undo/Redo Moves: Supports undo and redo functionalities, allowing users to navigate through their move history during gameplay.
  • Automated Agent Moves: Incorporates BitBully, a Connect-4 backend engine, enabling computer-generated moves and board evaluations.
  • Game State Handling: Detects game-over scenarios, including win/draw conditions, and provides immediate user feedback through popup alerts.

Attributes:

Name Type Description
notify_output Output

Output widget for notifications and popups.

Examples:

Generally, you should this method to retreive and display the widget.

>>> %matplotlib ipympl
>>> c4gui = GuiC4()
>>> display(c4gui.get_widget())
Source code in src/bitbully/gui_c4.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
class GuiC4:
    """A class which allows to create an interactive Connect-4 widget.

    GuiC4 is an interactive Connect-4 graphical user interface (GUI) implemented using
    Matplotlib, IPython widgets, and a backend agent from the BitBully engine. It
    provides the following main features:

    - Interactive Game Board: Presents a dynamic 6-row by 7-column
        Connect-4 board with clickable board cells.
    - Matplotlib Integration: Utilizes Matplotlib figures
        to render high-quality game visuals directly within Jupyter notebook environments.
    - User Interaction: Captures and processes mouse clicks and button events, enabling
        intuitive gameplay via either direct board interaction or button controls.
    - Undo/Redo Moves: Supports undo and redo functionalities, allowing users to
        navigate through their move history during gameplay.
    - Automated Agent Moves: Incorporates BitBully, a Connect-4 backend engine, enabling
        computer-generated moves and board evaluations.
    - Game State Handling: Detects game-over scenarios, including win/draw conditions,
        and provides immediate user feedback through popup alerts.

    Attributes:
        notify_output (widgets.Output): Output widget for notifications and popups.

    Examples:
            Generally, you should this method to retreive and display the widget.

            ```pycon
            >>> %matplotlib ipympl
            >>> c4gui = GuiC4()
            >>> display(c4gui.get_widget())
            ```

    """

    def __init__(self) -> None:
        """Init the GuiC4 widget."""
        # Create a logger with the class name
        self.m_logger = logging.getLogger(self.__class__.__name__)
        self.m_logger.setLevel(logging.DEBUG)  # Set the logging level

        # Create a console handler (optional)
        ch = logging.StreamHandler()
        ch.setLevel(logging.INFO)  # Set level for the handler

        # Create a formatter and add it to the handler
        formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
        ch.setFormatter(formatter)

        # Add the handler to the logger
        self.m_logger.addHandler(ch)

        # Avoid adding handlers multiple times
        self.m_logger.propagate = False
        assets_pth = Path(str(importlib.resources.files("bitbully").joinpath("assets")))
        png_empty = plt.imread(assets_pth.joinpath("empty.png"), format=None)
        png_empty_m = plt.imread(assets_pth.joinpath("empty_m.png"), format=None)
        png_empty_r = plt.imread(assets_pth.joinpath("empty_r.png"), format=None)
        png_red = plt.imread(assets_pth.joinpath("red.png"), format=None)
        png_red_m = plt.imread(assets_pth.joinpath("red_m.png"), format=None)
        png_yellow = plt.imread(assets_pth.joinpath("yellow.png"), format=None)
        png_yellow_m = plt.imread(assets_pth.joinpath("yellow_m.png"), format=None)
        self.m_png = {
            0: {"plain": png_empty, "corner": png_empty_m, "underline": png_empty_r},
            1: {"plain": png_yellow, "corner": png_yellow_m},
            2: {"plain": png_red, "corner": png_red_m},
        }

        self.m_n_row, self.m_n_col = 6, 7

        # TODO: probably not needed:
        self.m_height = np.zeros(7, dtype=np.int32)

        self.m_board_size = 3.5
        # self.m_player = 1
        self.is_busy = False

        self.last_event_time = time.time()

        # Create board first
        self._create_board()

        # Generate buttons for inserting the tokens:
        self._create_buttons()

        # Create control buttons
        self._create_control_buttons()

        # Capture clicks on the field
        _ = self.m_fig.canvas.mpl_connect("button_press_event", self._on_field_click)

        # Movelist
        self.m_movelist: list[tuple[int, int, int]] = []

        # Redo list
        self.m_redolist: list[tuple[int, int, int]] = []

        # Gameover flag:
        self.m_gameover = False

        # C4 agent
        # db_path = importlib.resources.files("bitbully").joinpath("assets/book_12ply_distances.dat")
        # self.bitbully_agent = bitbully_core.BitBullyCore(db_path)

        resource = importlib.resources.files("bitbully").joinpath("assets/book_12ply_distances.dat")
        with importlib.resources.as_file(resource) as db_path:
            self.bitbully_agent = bitbully_core.BitBullyCore(db_path)

    def _reset(self) -> None:
        self.m_movelist = []
        self.m_redolist = []
        self.m_height = np.zeros(7, dtype=np.int32)
        self.m_gameover = False

        for im in self.ims:
            im.set_data(self.m_png[0]["plain"])

        self.m_fig.canvas.draw_idle()
        self.m_fig.canvas.flush_events()
        self._update_insert_buttons()

    def _get_fig_size_px(self) -> npt.NDArray[np.float64]:
        # Get the size in inches
        size_in_inches = self.m_fig.get_size_inches()
        self.m_logger.debug("Figure size in inches: %f", size_in_inches)

        # Get the DPI
        dpi = self.m_fig.dpi
        self.m_logger.debug("Figure DPI: %d", dpi)

        # Convert to pixels
        return size_in_inches * dpi

    def _create_control_buttons(self) -> None:
        self.m_control_buttons = {}

        # Create buttons for each column
        self.m_logger.debug("Figure size: ", self._get_fig_size_px())

        fig_size_px = self._get_fig_size_px()
        wh = f"{-3 + (fig_size_px[1] / self.m_n_row)}px"
        btn_layout = Layout(height=wh, width=wh)

        button = Button(description="🔄", tooltip="Reset Game", layout=btn_layout)
        button.on_click(lambda b: self._reset())
        self.m_control_buttons["reset"] = button

        button = Button(description="↩️", tooltip="Undo Move", layout=btn_layout)
        button.disabled = True
        button.on_click(lambda b: self._undo_move())
        self.m_control_buttons["undo"] = button

        button = Button(description="↪️", tooltip="Redo Move", layout=btn_layout)
        button.disabled = True
        button.on_click(lambda b: self._redo_move())
        self.m_control_buttons["redo"] = button

        button = Button(description="🕹️", tooltip="Computer Move", layout=btn_layout)
        button.on_click(lambda b: self._computer_move())
        self.m_control_buttons["move"] = button

        button = Button(description="📊", tooltip="Evaluate Board", layout=btn_layout)
        self.m_control_buttons["evaluate"] = button

    def _computer_move(self) -> None:
        self.is_busy = True
        self._update_insert_buttons()
        b = bitbully_core.BoardCore()
        assert b.setBoard([mv[1] for mv in self.m_movelist])
        move_scores = self.bitbully_agent.scoreMoves(b)
        self.is_busy = False
        self._insert_token(int(np.argmax(move_scores)))

    def _create_board(self) -> None:
        self.output = Output()

        with self.output:
            fig, axs = plt.subplots(
                self.m_n_row,
                self.m_n_col,
                figsize=(
                    self.m_board_size / self.m_n_row * self.m_n_col,
                    self.m_board_size,
                ),
            )
            axs = axs.flatten()
            self.ims = []
            for ax in axs:
                self.ims.append(ax.imshow(self.m_png[0]["plain"], animated=True))
                ax.axis("off")
                ax.set_xticklabels([])
                ax.set_yticklabels([])

            fig.tight_layout()
            plt.subplots_adjust(wspace=0.05, hspace=0.05, left=0.0, right=1.0, top=1.0, bottom=0.0)
            fig.suptitle("")
            fig.set_facecolor("darkgray")
            fig.canvas.toolbar_visible = False  # type: ignore[attr-defined]
            fig.canvas.resizable = False  # type: ignore[attr-defined]
            fig.canvas.toolbar_visible = False  # type: ignore[attr-defined]
            fig.canvas.header_visible = False  # type: ignore[attr-defined]
            fig.canvas.footer_visible = False  # type: ignore[attr-defined]
            fig.canvas.capture_scroll = True  # type: ignore[attr-defined]
            plt.show(block=False)

        self.m_fig = fig
        self.m_axs = axs

    notify_output: widgets.Output = widgets.Output()
    display(notify_output)

    @notify_output.capture()
    def _popup(self, text: str) -> None:
        clear_output()
        display(Javascript(f"alert('{text}')"))

    def _is_legal_move(self, col: int) -> bool:
        return not self.m_height[col] >= self.m_n_row

    def _insert_token(self, col: int, reset_redo_list: bool = True) -> None:
        if self.is_busy:
            return
        self.is_busy = True

        for button in self.m_insert_buttons:
            button.disabled = True

        board = bitbully_core.BoardCore()
        board.setBoard([mv[1] for mv in self.m_movelist])
        if self.m_gameover or not board.playMove(col):
            self._update_insert_buttons()
            self.is_busy = False
            return

        try:
            # Get player
            player = 1 if not self.m_movelist else 3 - self.m_movelist[-1][0]
            self.m_movelist.append((player, col, self.m_height[col]))
            self._paint_token()
            self.m_height[col] += 1

            # Usually, after a move is performed, there is no possibility to
            # redo a move again
            if reset_redo_list:
                self.m_redolist = []

            self._check_winner(board)

        except Exception as e:
            self.m_logger.error("Error: %s", str(e))
            raise
        finally:
            time.sleep(0.5)  # debounce button
            # Re-enable all buttons (if columns not full)
            self.is_busy = False
            self._update_insert_buttons()

    def _redo_move(self) -> None:
        if len(self.m_redolist) < 1:
            return
        _p, col, _row = self.m_redolist.pop()
        self._insert_token(col, reset_redo_list=False)

    def _undo_move(self) -> None:
        if len(self.m_movelist) < 1:
            return

        if self.is_busy:
            return
        self.is_busy = True

        try:
            _p, col, row = mv = self.m_movelist.pop()
            self.m_redolist.append(mv)

            self.m_height[col] -= 1
            assert row == self.m_height[col]

            img_idx = self._get_img_idx(col, row)

            self.ims[img_idx].set_data(self.m_png[0]["plain"])
            self.m_axs[img_idx].draw_artist(self.ims[img_idx])
            if len(self.m_movelist) > 0:
                self._paint_token()
            else:
                self.m_fig.canvas.blit(self.ims[img_idx].get_clip_box())
                self.m_fig.canvas.flush_events()

            self.m_gameover = False

        except Exception as e:
            self.m_logger.error("Error: %s", str(e))
            raise
        finally:
            # Re-enable all buttons (if columns not full)
            self.is_busy = False
            self._update_insert_buttons()

            time.sleep(0.5)  # debounce button

    def _update_insert_buttons(self) -> None:
        for button, col in zip(self.m_insert_buttons, range(self.m_n_col)):
            button.disabled = bool(self.m_height[col] >= self.m_n_row) or self.m_gameover or self.is_busy

        self.m_control_buttons["undo"].disabled = len(self.m_movelist) < 1 or self.is_busy
        self.m_control_buttons["redo"].disabled = len(self.m_redolist) < 1 or self.is_busy
        self.m_control_buttons["move"].disabled = self.m_gameover or self.is_busy
        self.m_control_buttons["evaluate"].disabled = self.m_gameover or self.is_busy

    def _get_img_idx(self, col: int, row: int) -> int:
        """Translates a column and row ID into the corresponding image ID.

        Args:
            col (int): column (0-6) of the considered board cell.
            row (int): row (0-5) of the considered board cell.

        Returns:
            int: The corresponding image id (0-41).
        """
        self.m_logger.debug("Got column: %d", col)

        return col % self.m_n_col + (self.m_n_row - row - 1) * self.m_n_col

    def _paint_token(self) -> None:
        if len(self.m_movelist) < 1:
            return

        p, col, row = self.m_movelist[-1]
        img_idx = self._get_img_idx(col, row)
        self.m_logger.debug("Paint token: %d", img_idx)

        #
        # no need to reset background, since we anyhow overwrite it again
        # self.m_fig.canvas.restore_region(self.m_background[img_idx])
        self.ims[img_idx].set_data(self.m_png[p]["corner"])

        # see: https://matplotlib.org/3.4.3/Matplotlib.pdf
        #      2.3.1 Faster rendering by using blitting
        blit_boxes = []
        self.m_axs[img_idx].draw_artist(self.ims[img_idx])
        blit_boxes.append(self.ims[img_idx].get_clip_box())
        # self.m_fig.canvas.blit()

        if len(self.m_movelist) > 1:
            # Remove the white corners for the second-to-last move
            # TODO: redundant code above
            p, col, row = self.m_movelist[-2]
            img_idx = self._get_img_idx(col, row)
            self.ims[img_idx].set_data(self.m_png[p]["plain"])
            self.m_axs[img_idx].draw_artist(self.ims[img_idx])
            blit_boxes.append(self.ims[img_idx].get_clip_box())

        self.m_fig.canvas.blit(blit_boxes[0])

        # self.m_fig.canvas.restore_region(self.m_background[img_idx])
        # self.m_fig.canvas.blit(self.ims[img_idx].get_clip_box())
        # self.m_fig.canvas.draw_idle()
        self.m_fig.canvas.flush_events()

    def _create_buttons(self) -> None:
        # Create buttons for each column
        self.m_logger.debug("Figure size: ", self._get_fig_size_px())

        fig_size_px = self._get_fig_size_px()

        self.m_insert_buttons = []
        for col in range(self.m_n_col):
            button = Button(
                description="⏬",
                layout=Layout(width=f"{-3 + (fig_size_px[0] / self.m_n_col)}px", height="50px"),
            )
            button.on_click(lambda b, col=col: self._insert_token(col))
            self.m_insert_buttons.append(button)

    def _create_column_labels(self) -> HBox:
        """Creates a row with the column labels 'a' to 'g'.

        Returns:
            HBox: A row of textboxes containing the columns labels 'a' to 'g'.
        """
        fig_size_px = self._get_fig_size_px()
        width = f"{-3 + (fig_size_px[0] / self.m_n_col)}px"
        textboxes = [
            widgets.Label(
                value=chr(ord("a") + i),
                layout=Layout(justify_content="center", align_items="center", width=width),
            )
            for i in range(self.m_n_col)
        ]
        return HBox(
            textboxes,
            layout=Layout(
                display="flex",
                flex_flow="row wrap",  # or "column" depending on your layout needs
                justify_content="center",  # Left alignment
                align_items="center",  # Top alignment
            ),
        )

    def _on_field_click(self, event: mpl_backend_bases.Event) -> None:
        """Based on the column where the click was detected, insert a token.

        Args:
            event (mpl_backend_bases.Event): A matplotlib mouse event.
        """
        if isinstance(event, mpl_backend_bases.MouseEvent):
            ix, iy = event.xdata, event.ydata
            self.m_logger.debug("click (x,y): %d, %d", ix, iy)
            idx = np.where(self.m_axs == event.inaxes)[0][0] % self.m_n_col
            self._insert_token(idx)

    def get_widget(self) -> AppLayout:
        """Get the widget.

        Examples:
            Generally, you should this method to retreive and display the widget.

            ```pycon
            >>> %matplotlib ipympl
            >>> c4gui = GuiC4()
            >>> display(c4gui.get_widget())
            ```

        Returns:
            AppLayout: the widget.
        """
        # Arrange buttons in a row
        insert_button_row = HBox(
            self.m_insert_buttons,
            layout=Layout(
                display="flex",
                flex_flow="row wrap",  # or "column" depending on your layout needs
                justify_content="center",  # Left alignment
                align_items="center",  # Top alignment
            ),
        )
        control_buttons_col = HBox(
            [VBox(list(self.m_control_buttons.values()))],
            layout=Layout(
                display="flex",
                flex_flow="row wrap",  # or "column" depending on your layout needs
                justify_content="flex-end",  # Left alignment
                align_items="center",  # Top alignment
            ),
        )

        tb = self._create_column_labels()

        return AppLayout(
            header=None,
            left_sidebar=control_buttons_col,
            center=VBox(
                [insert_button_row, self.output, tb],
                layout=Layout(
                    display="flex",
                    flex_flow="column wrap",
                    justify_content="flex-start",  # Left alignment
                    align_items="flex-start",  # Top alignment
                ),
            ),
            footer=None,
            right_sidebar=None,
        )

    def _check_winner(self, board: bitbully_core.BoardCore) -> None:
        """Check for Win or draw."""
        if board.hasWin():
            winner = "Yellow" if board.movesLeft() % 2 else "Red"
            self._popup(f"Game over! {winner} wins!")
            self.m_gameover = True
        if board.movesLeft() == 0:
            self._popup("Game over! Draw!")
            self.m_gameover = True

    def destroy(self) -> None:
        """Destroy and release the acquired resources."""
        plt.close(self.m_fig)
        del self.bitbully_agent
        del self.m_axs
        del self.m_fig
        del self.output

__init__()

Init the GuiC4 widget.

Source code in src/bitbully/gui_c4.py
def __init__(self) -> None:
    """Init the GuiC4 widget."""
    # Create a logger with the class name
    self.m_logger = logging.getLogger(self.__class__.__name__)
    self.m_logger.setLevel(logging.DEBUG)  # Set the logging level

    # Create a console handler (optional)
    ch = logging.StreamHandler()
    ch.setLevel(logging.INFO)  # Set level for the handler

    # Create a formatter and add it to the handler
    formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
    ch.setFormatter(formatter)

    # Add the handler to the logger
    self.m_logger.addHandler(ch)

    # Avoid adding handlers multiple times
    self.m_logger.propagate = False
    assets_pth = Path(str(importlib.resources.files("bitbully").joinpath("assets")))
    png_empty = plt.imread(assets_pth.joinpath("empty.png"), format=None)
    png_empty_m = plt.imread(assets_pth.joinpath("empty_m.png"), format=None)
    png_empty_r = plt.imread(assets_pth.joinpath("empty_r.png"), format=None)
    png_red = plt.imread(assets_pth.joinpath("red.png"), format=None)
    png_red_m = plt.imread(assets_pth.joinpath("red_m.png"), format=None)
    png_yellow = plt.imread(assets_pth.joinpath("yellow.png"), format=None)
    png_yellow_m = plt.imread(assets_pth.joinpath("yellow_m.png"), format=None)
    self.m_png = {
        0: {"plain": png_empty, "corner": png_empty_m, "underline": png_empty_r},
        1: {"plain": png_yellow, "corner": png_yellow_m},
        2: {"plain": png_red, "corner": png_red_m},
    }

    self.m_n_row, self.m_n_col = 6, 7

    # TODO: probably not needed:
    self.m_height = np.zeros(7, dtype=np.int32)

    self.m_board_size = 3.5
    # self.m_player = 1
    self.is_busy = False

    self.last_event_time = time.time()

    # Create board first
    self._create_board()

    # Generate buttons for inserting the tokens:
    self._create_buttons()

    # Create control buttons
    self._create_control_buttons()

    # Capture clicks on the field
    _ = self.m_fig.canvas.mpl_connect("button_press_event", self._on_field_click)

    # Movelist
    self.m_movelist: list[tuple[int, int, int]] = []

    # Redo list
    self.m_redolist: list[tuple[int, int, int]] = []

    # Gameover flag:
    self.m_gameover = False

    # C4 agent
    # db_path = importlib.resources.files("bitbully").joinpath("assets/book_12ply_distances.dat")
    # self.bitbully_agent = bitbully_core.BitBullyCore(db_path)

    resource = importlib.resources.files("bitbully").joinpath("assets/book_12ply_distances.dat")
    with importlib.resources.as_file(resource) as db_path:
        self.bitbully_agent = bitbully_core.BitBullyCore(db_path)

destroy()

Destroy and release the acquired resources.

Source code in src/bitbully/gui_c4.py
def destroy(self) -> None:
    """Destroy and release the acquired resources."""
    plt.close(self.m_fig)
    del self.bitbully_agent
    del self.m_axs
    del self.m_fig
    del self.output

get_widget()

Get the widget.

Examples:

Generally, you should this method to retreive and display the widget.

>>> %matplotlib ipympl
>>> c4gui = GuiC4()
>>> display(c4gui.get_widget())

Returns:

Name Type Description
AppLayout AppLayout

the widget.

Source code in src/bitbully/gui_c4.py
def get_widget(self) -> AppLayout:
    """Get the widget.

    Examples:
        Generally, you should this method to retreive and display the widget.

        ```pycon
        >>> %matplotlib ipympl
        >>> c4gui = GuiC4()
        >>> display(c4gui.get_widget())
        ```

    Returns:
        AppLayout: the widget.
    """
    # Arrange buttons in a row
    insert_button_row = HBox(
        self.m_insert_buttons,
        layout=Layout(
            display="flex",
            flex_flow="row wrap",  # or "column" depending on your layout needs
            justify_content="center",  # Left alignment
            align_items="center",  # Top alignment
        ),
    )
    control_buttons_col = HBox(
        [VBox(list(self.m_control_buttons.values()))],
        layout=Layout(
            display="flex",
            flex_flow="row wrap",  # or "column" depending on your layout needs
            justify_content="flex-end",  # Left alignment
            align_items="center",  # Top alignment
        ),
    )

    tb = self._create_column_labels()

    return AppLayout(
        header=None,
        left_sidebar=control_buttons_col,
        center=VBox(
            [insert_button_row, self.output, tb],
            layout=Layout(
                display="flex",
                flex_flow="column wrap",
                justify_content="flex-start",  # Left alignment
                align_items="flex-start",  # Top alignment
            ),
        ),
        footer=None,
        right_sidebar=None,
    )