コード例 #1
0
ファイル: engine.py プロジェクト: dsiegler2000/Coeus
 def set_position(self, lag: List[str], starting_fen=chess.STARTING_FEN):
     """
     Sets the internal board state to that specified by the series of long algebraic notation moves given
     (starting from the specified start position).
     :param lag: List of long algebraic notation moves
     :param starting_fen: The FEN to start the board at
     :return: None
     """
     self.board = SearchBoard(fen=starting_fen)
     for move in lag:
         self.board.push(chess.Move.from_uci(move))
コード例 #2
0
ファイル: search.py プロジェクト: dsiegler2000/Coeus
 def _zugzwang(board: SearchBoard) -> bool:
     """
     Detects so called zugzwang positions, wherein not moving is actually more benificial than moving (often happens
     in the end game when trying to mate the king). Note that this method is only a heuristic and technically
     doesn't cover every case.
     :param board: Board to check
     :return: True if zugzwang, False otherwise
     """
     # TODO improve zugzwang detection (see brucemo.com)
     return board.pieces_mask(chess.KNIGHT, board.turn) == 0 and \
            board.pieces_mask(chess.BISHOP, board.turn) == 0 and \
            board.pieces_mask(chess.ROOK, board.turn) == 0 and \
            board.pieces_mask(chess.QUEEN, board.turn) == 0
コード例 #3
0
ファイル: evaluation.py プロジェクト: dsiegler2000/Coeus
 def _compute_passed_isolated_pawns(
         self, board: SearchBoard,
         piece_list: PieceList) -> Tuple[int, int, int, int]:
     """
     Computes the score for white and black passed and isolated pawns.
     :param board: Board to compute on
     :param piece_list: Piece list
     :return: white passed pawn score, black, white isolated pawn score, black
     """
     white_pawns = board.pieces(chess.PAWN, chess.WHITE).mask
     black_pawns = board.pieces(chess.PAWN, chess.BLACK).mask
     white_passed, black_passed = 0, 0
     white_isolated, black_isolated = 0, 0
     for sq in piece_list.square_list(chess.PAWN, chess.WHITE):
         # Pawn present and it is passed (note that pawn is guaranteed to be here)
         if not self._white_passed_pawns_bbs[sq] & black_pawns:
             white_passed += self.pawn_passed[sq // 8]
         # Pawn present and is isolated
         if not self._isolated_pawns_bbs[sq] & white_pawns:
             white_isolated += self.pawn_isolated
     for sq in piece_list.square_list(chess.PAWN, chess.BLACK):
         if not self._black_passed_pawns_bbs[sq] & white_pawns:
             black_passed += self.pawn_passed[7 - (sq // 8)]
         if not self._isolated_pawns_bbs[sq] & black_pawns:
             black_isolated += self.pawn_isolated
     return white_passed, black_passed, white_isolated, black_isolated
コード例 #4
0
ファイル: search.py プロジェクト: dsiegler2000/Coeus
    def _probe_endgame_tablebase(
            self,
            board: SearchBoard) -> Tuple[Optional[chess.Move], Optional[int]]:
        with chess.gaviota.PythonTablebase() as tablebase:
            tablebase.add_directory(self.endgame_tablebase_filepath)
            curr_dtm = tablebase.get_dtm(board)
            # If playing for a stalemate then no point in probing
            if curr_dtm is not None and curr_dtm != 0:
                max_dtm = -1_000_000
                max_dtm_move = None
                for move in board.legal_moves:
                    board.push(move)
                    outcome = board.outcome()
                    considering_dtm = tablebase.get_dtm(board)
                    no_draw = outcome is None or outcome.termination == chess.Termination.CHECKMATE
                    mate_move = outcome is not None and outcome.termination == chess.Termination.CHECKMATE

                    # The case where a DTM of 0 means that we are mating
                    if 0 <= curr_dtm <= 3 and mate_move and considering_dtm == 0:
                        board.pop()
                        return move, considering_dtm
                    else:  # Otherwise, DTM of 0 means draw
                        if curr_dtm > 0:
                            optimizes = max_dtm < considering_dtm < 0
                        else:
                            optimizes = considering_dtm > max_dtm and considering_dtm > 0
                        if considering_dtm is not None and no_draw and optimizes:
                            max_dtm = considering_dtm
                            max_dtm_move = move
                        board.pop()
                return max_dtm_move, max_dtm
            return None, None
コード例 #5
0
def generate_ordered_moves_v1(board: SearchBoard, pv_move: chess.Move, killer_moves: List[List[chess.Move]],
                              search_history: List[List[int]], captures_only: bool = False) \
        -> Generator[chess.Move, None, None]:
    """
    Generates ordered moves using the following heuristics in this order:
    - principal variation move
    - most valuable victim, least valuable attacker captures (MVV LVA)
    - killer moves (beta cutoff but aren't captures)
    - search history (alpha cutoff but not beta cutoff and not a capture)
    :param board: The board to generate moves for
    :param pv_move: The principal variation move
    :param killer_moves: The killer moves table, a 2 x max_depth size array with the 0th index containing current
    killers and the 1st index containing next killers
    :param search_history: The search history table, a 64 x 64 size array that provides a heatmap of "relevant"
    (alpha cutoff meeting but not beta or capture) moves
    :param captures_only: True if only captures should be generated, False otherwise
    :return:
    """
    scored_moves: List[_ScoredMove] = []
    moves = board.generate_legal_captures(
    ) if captures_only else board.legal_moves
    for move in moves:
        if board.is_capture(move):
            attacker = board.piece_at(move.from_square).piece_type
            victim_piece = board.piece_at(move.to_square)
            if not victim_piece:  # en passant
                victim = chess.PAWN
            else:
                victim = victim_piece.piece_type
            score = MVV_LVA_SCORES[victim][attacker] + 10_000_000
        # "Quiet" moves
        elif killer_moves[0][
                board.searching_ply] == move:  # Current killer moves
            score = 9_000_000
        elif killer_moves[1][board.searching_ply] == move:  # Next killer moves
            score = 8_000_000
        else:  # Simply use search history heatmap
            score = search_history[move.from_square][move.to_square]

        # Principal variation should always be considered first
        if move == pv_move:
            score = 20_000_000
        scored_moves.append(_ScoredMove(move, score))
    return ordered_move_generator(scored_moves)
コード例 #6
0
ファイル: evaluation.py プロジェクト: dsiegler2000/Coeus
 def _count_piece_type(board: SearchBoard,
                       piece_type: chess.PieceType) -> Tuple[int, int]:
     """
     Counts how many occurrences of the given piece type are on the board.
     :param board: Board to consider
     :param piece_type: Piece type to count
     :return: The piece count for white, black
     """
     return (count_bin_ones(board.pieces_mask(piece_type, chess.WHITE)),
             count_bin_ones(board.pieces_mask(piece_type, chess.BLACK)))
コード例 #7
0
ファイル: test_evaluation.py プロジェクト: dsiegler2000/Coeus
def test_boards():
    with open(os.path.join("testing/mirror.epd"), "r",
              encoding="iso8859-1") as mirror:
        while True:
            line = mirror.readline()
            if line is None or line.strip() == "":
                break
            position_fen = line.split(" ")[0]
            fen = f"{position_fen} w KQkq - 0 1"
            board = SearchBoard(fen=fen)
            yield board
コード例 #8
0
ファイル: search.py プロジェクト: dsiegler2000/Coeus
    def _quiescence(self, alpha: int, beta: int, board: SearchBoard) -> int:
        self.search_info.nodes += 1

        evaluation = self.evaluator.evaluate_board(board)

        if self._terminal_condition(board):
            return 0

        score = evaluation.board_evaluation

        if board.searching_ply > self.max_depth - 1:
            return score

        if score >= beta:  # Beta cutoff
            return beta

        if score > alpha:  # Standing pattern
            alpha = score

        # From here, it is basically minimax alpha beta using only capture moves
        num_legal_moves = 0

        for move in self.move_orderer(board, None, captures_only=True):
            if move in self.search_info.excluded_moves:
                continue
            num_legal_moves += 1
            board.push(move)
            score = -self._quiescence(-beta, -alpha, board)
            board.pop()

            if self.search_info.stop:
                return 0

            if score > alpha:
                if score >= beta:
                    self.search_info.fail_high_first += 1 if num_legal_moves == 1 else 0
                    self.search_info.fail_high += 1
                    return beta
                alpha = score

        return alpha
コード例 #9
0
ファイル: evaluation.py プロジェクト: dsiegler2000/Coeus
 def apply_heatmap_dynamic(self, board: SearchBoard,
                           color: chess.Color) -> int:
     """
     Applies a heatmap to a given board. This implementation dynamically locates pieces and thus it is slower than
     `apply_heatmap`, which should be used if possible.
     :param board: The board to apply the heatmap to
     :param color: The color to apply the heatmap to
     :return: The sum of the heatmap times 1 if a piece of the given color and `self.piece_type`
     """
     piece_mask = board.pieces_mask(self.piece_type, color)
     heatmap = self._white_heatmap if color == chess.WHITE else self._black_heatmap
     return sum(v * (1 if piece_mask & (1 << i) else 0)
                for i, v in enumerate(heatmap))
コード例 #10
0
ファイル: engine.py プロジェクト: dsiegler2000/Coeus
    def __init__(self, config_filepath: str, log_func: Callable[[str], None]):
        """
        Creates an engine using the given searcher.
        :param config_filepath: File path to the config file
        :param log_func: The function to use to log data
        """
        self.config_filepath = config_filepath
        self.board: SearchBoard = SearchBoard()

        with open(self.config_filepath, "r+") as config_f:
            self.config = json.load(config_f)

        self._parse_config()

        # Stop flag (for pondering)
        self._stop = False

        # Logging information
        self.log_func: Callable[[str], None] = log_func
        self._time_last_log: Optional[float] = None
        self._nodes_last_log: Optional[int] = None
コード例 #11
0
ファイル: evaluation.py プロジェクト: dsiegler2000/Coeus
    def _compute_open_files(self, board: SearchBoard,
                            piece_list: PieceList) -> Tuple[int, int]:
        """
        Computes the score for occupying open files (for rooks and queens)
        :param board: Board to consider
        :param piece_list: Piece list of the board
        :return: White open file score, black open file score
        """
        white_pawns = board.pieces(chess.PAWN, chess.WHITE).mask
        black_pawns = board.pieces(chess.PAWN, chess.BLACK).mask
        pawns = white_pawns | black_pawns
        open_file = lambda s: not (pawns & self._file_bbs[s % 8])
        white_semiopen_file = lambda s: not (white_pawns & self._file_bbs[s % 8
                                                                          ])
        black_semiopen_file = lambda s: not (black_pawns & self._file_bbs[s % 8
                                                                          ])
        white, black = 0, 0
        for sq in piece_list.square_list(chess.ROOK, chess.WHITE):
            if open_file(sq):
                white += self.rook_open_file
            elif white_semiopen_file(sq):
                white += self.rook_semiopen_file
        for sq in piece_list.square_list(chess.ROOK, chess.BLACK):
            if open_file(sq):
                black += self.rook_open_file
            elif black_semiopen_file(sq):
                black += self.rook_semiopen_file

        # Queens
        for sq in piece_list.square_list(chess.QUEEN, chess.WHITE):
            if open_file(sq):
                white += self.queen_open_file
            elif white_semiopen_file(sq):
                white += self.queen_semiopen_file
        for sq in piece_list.square_list(chess.QUEEN, chess.BLACK):
            if open_file(sq):
                black += self.queen_open_file
            elif black_semiopen_file(sq):
                black += self.queen_semiopen_file
        return white, black
コード例 #12
0
ファイル: search.py プロジェクト: dsiegler2000/Coeus
 def _init_search_info(self,
                       board: SearchBoard = None,
                       time_to_think: float = None,
                       nodes: int = None) -> SearchBoard:
     """
     Clears all relevant searching arrays and sets up the search information.
     :param board: The board to clear out too, if needed
     :param time_to_think: Time to think in seconds
     :param nodes: Number of nodes to search
     :return: The updated board, if passed in
     """
     self.search_info = _SearchInfo(time_to_think=time_to_think,
                                    nodes=nodes)
     self.search_history: List[List[int]] = [[
         0 for s in range(len(chess.SQUARES))
     ] for p in range(len(chess.SQUARES))]
     self.search_killers: List[List[chess.Move]] = [[
         chess.Move.null() for d in range(self.max_depth)
     ] for _ in range(2)]
     if board:  # Reset the board's information
         board.searching_ply = 0
     return board
コード例 #13
0
ファイル: evaluation.py プロジェクト: dsiegler2000/Coeus
    def evaluate_board(self, board: SearchBoard) -> BoardEvaluation:
        piece_list = board.generate_piece_list()

        # Pawns must also be checked
        if len(piece_list.square_list(chess.PAWN, chess.WHITE)) == 0 and \
                len(piece_list.square_list(chess.PAWN, chess.BLACK)) == 0 and \
                self._is_material_draw(piece_list):
            return BoardEvaluation(0, 0, board.turn, True, True, 0)

        # Compute all values (all in centipawns)
        white_materials, black_materials, num_men = self._compute_materials(
            self.piece_values, piece_list)
        white_positions = self._compute_positions(chess.WHITE, piece_list,
                                                  white_materials,
                                                  black_materials)
        black_positions = self._compute_positions(chess.BLACK, piece_list,
                                                  white_materials,
                                                  black_materials)
        passed_isolated = self._compute_passed_isolated_pawns(
            board, piece_list)
        white_passed, black_passed, white_isolated, black_isolated = passed_isolated
        white_open_files, black_open_files = self._compute_open_files(
            board, piece_list)
        white_bishop_pair = self.bishop_pair if len(
            piece_list.square_list(chess.BISHOP, chess.WHITE)) >= 2 else 0
        black_bishop_pair = self.bishop_pair if len(
            piece_list.square_list(chess.BISHOP, chess.BLACK)) >= 2 else 0

        # Sum it all up
        white = white_materials + white_positions + white_passed + white_isolated + white_open_files + white_bishop_pair
        black = black_materials + black_positions + black_passed + black_isolated + black_open_files + black_bishop_pair

        return BoardEvaluation(
            white, black, board.turn,
            self._is_endgame(chess.WHITE, white_materials, black_materials),
            self._is_endgame(chess.BLACK, white_materials, black_materials),
            num_men)
コード例 #14
0
ファイル: search.py プロジェクト: dsiegler2000/Coeus
    def search(self,
               board: SearchBoard,
               fixed_time: Optional[float],
               target_depth: int,
               target_nodes: Optional[int],
               on_depth_completed: Callable[[int, int, List[chess.Move], str],
                                            None] = None,
               log_func: Callable[[str], None] = None,
               use_opening_book: bool = True,
               use_endgame_tablebase: bool = True) -> List[chess.Move]:
        """
        Performs iterative deepening minimax (negamax) search with alpha-beta pruning. Also handles checking opening
        and closing books.
        :param board: Board to search
        :param fixed_time: Time to use for the search, in seconds
        :param target_depth: Depth to search to
        :param target_nodes: Number of nodes to search
        :param on_depth_completed: A callback that is run after each successive depth is completed
        :param log_func: Logging function to use
        :param use_opening_book: Whether to use the opening book
        :param use_endgame_tablebase: Whether to use the endgame tablebase
        :return: The PV line found
        """
        # Parse parameters
        if target_depth is None:
            target_depth = self.max_depth
        if log_func is None:
            log_func = lambda s: None

        board = self._init_search_info(board,
                                       time_to_think=fixed_time,
                                       nodes=target_nodes)

        # Check for opening book entry
        if use_opening_book:
            move = self._probe_opening_book(board)
            if move is not None:
                log_func("opening book hit")
                return [move]

        # Check endgame tables
        if use_endgame_tablebase:
            move, dtm = self._probe_endgame_tablebase(board)
            if move is not None:
                log_func(f"endgame table hit, DTM: {dtm}")
                return [move]

        # Clear the transposition table when there is a transition to endgame
        if len(board.move_stack) > 0:
            move = board.pop()
            prev_evaluation = self.evaluator.evaluate_board(board)
            board.push(move)
            evaluation = self.evaluator.evaluate_board(board)
            if evaluation.white_endgame != prev_evaluation.white_endgame or \
                    evaluation.black_endgame != prev_evaluation.black_endgame:
                board.clear_transposition_table()
                log_func(
                    "transposition table cleared due to transition to endgame")

        # Set up the checkup daemon thread
        timer = call_repeatedly(0.5, self._checkup)

        # Iterative deepening
        prev_elapsed_time = 0
        best_score = None
        max_depth = 0
        err_log = dict()
        for curr_depth in range(1, target_depth + 1):
            st = time.time()
            self.search_info.curr_depth = curr_depth
            best_score = self._minimax_alpha_beta(-self.inf, self.inf, board,
                                                  curr_depth, True)
            self._checkup()

            if self.search_info.stop:
                break

            max_depth = curr_depth
            ordering = self.search_info.fail_high_first / self.search_info.fail_high \
                if self.search_info.fail_high > 0 else 0.0

            # elapsed_time = time.time() - st
            # log_func(f"Depth {curr_depth} took time {elapsed_time}")
            # if curr_depth > 2:
            #     err = abs(predicted_time_next_iteration - elapsed_time)
            #     err_log[curr_depth] = err
            # if curr_depth > 1:
            #     # Effective branching factor
            #     time_ebf = elapsed_time / prev_elapsed_time
            #     predicted_time_next_iteration = time_ebf * elapsed_time
            #     log_func(f"Predicting that depth {curr_depth + 1} will take {round(predicted_time_next_iteration, 2)}s")
            #     # # TODO finish up the timecontrol classes and delegate to that via callbacks
            #     # # TODO this doesn't work when coming off of the transposition table, or maybe the error calculation doesn't work??
            #     # if self.search_info.end_time is not None:
            #     #     time_remaining = self.search_info.end_time - time.time()
            #     #     if time_remaining < 0.75 * predicted_time_next_iteration:
            #     #         break
            #
            # prev_elapsed_time = elapsed_time

            on_depth_completed(curr_depth, best_score,
                               board.generate_pv_line(curr_depth))

        # Stop the timer
        timer()

        log_func("Errors:")
        for d in range(1, target_depth + 1):
            if d in err_log:
                log_func(f"Depth {d}: {round(err_log[d], 2)}")

        return board.generate_pv_line(max_depth)
コード例 #15
0
ファイル: search.py プロジェクト: dsiegler2000/Coeus
    def _minimax_alpha_beta(self, alpha: int, beta: int, board: SearchBoard,
                            depth: int, null_pruning: bool) -> int:
        # Check depth terminal condition
        if depth <= 0:
            return self._quiescence(alpha, beta, board)
        self.search_info.nodes += 1

        if self._terminal_condition(
                board) and board.searching_ply > 0:  # Stalemate/draw condition
            return 0

        # Evaluate
        evaluation = self.evaluator.evaluate_board(board)

        if board.searching_ply > self.max_depth - 1:  # Max depth
            return evaluation.board_evaluation

        # In-check test to "get out of check"
        in_check = board.is_check()
        depth += 1 if in_check else 0

        # Prove transposition table and return early if we have a hit
        pv_move, score = board.probe_transposition_table(
            alpha, beta, depth, self.mate_value)

        if pv_move:
            return score

        # Null move pruning (must also verify that the side isn't in check and not in zugzwang scenario)
        if null_pruning and not in_check and board.searching_ply > 0 \
                and not self._zugzwang(board) and depth >= self.reduction_factor + 1:
            board.push(chess.Move.null())
            score = -self._minimax_alpha_beta(
                -beta, -beta + 1, board, depth - self.reduction_factor - 1,
                False)
            board.pop()
            if self.search_info.stop:
                return 0
            # If the null move option improves on beta and isn't a mate then return it
            if score >= beta and abs(score) < self.mate_value:
                return beta

        old_alpha = alpha
        best_move = None
        best_score = -self.inf
        num_legal_moves = 0
        found_pv = False

        for move in self.move_orderer(board, pv_move):
            if move in self.search_info.excluded_moves:
                continue
            num_legal_moves += 1
            board.push(move)

            if found_pv:  # PVS (principal variation search)
                score = -self._minimax_alpha_beta(-alpha - 1, -alpha, board,
                                                  depth - 1, True)
                if alpha < score < beta:
                    score = -self._minimax_alpha_beta(-beta, -alpha, board,
                                                      depth - 1, True)
            else:
                score = -self._minimax_alpha_beta(-beta, -alpha, board,
                                                  depth - 1, True)

            board.pop()

            if self.search_info.stop:
                return 0

            capture = board.is_capture(move)

            if score > best_score:
                best_score = score
                best_move = move
                if score > alpha:  # Alpha cutoff
                    if score >= beta:  # Beta cutoff
                        self.search_info.fail_high_first += 1 if num_legal_moves == 1 else 0
                        self.search_info.fail_high += 1
                        if not capture:  # Killer move (beta cutoff but not capture)
                            self.search_killers[1][
                                board.searching_ply] = self.search_killers[0][
                                    board.searching_ply]
                            self.search_killers[0][board.searching_ply] = move
                        board.store_transposition_table_entry(
                            best_move, beta, depth,
                            TranspositionTableFlag.BETA, self.mate_value)
                        return beta
                    found_pv = True
                    alpha = score
                    if not capture:  # Alpha cutoff that isn't a capture
                        self.search_history[best_move.from_square][
                            best_move.to_square] += depth

        if num_legal_moves == 0:  # Checkmate cases
            if in_check:
                return -self.inf + board.searching_ply
            else:  # Draw
                return 0

        if alpha != old_alpha:  # Principal variation
            board.store_transposition_table_entry(best_move, best_score, depth,
                                                  TranspositionTableFlag.EXACT,
                                                  self.mate_value)
        else:
            board.store_transposition_table_entry(best_move, alpha, depth,
                                                  TranspositionTableFlag.ALPHA,
                                                  self.mate_value)

        return alpha
コード例 #16
0
ファイル: search.py プロジェクト: dsiegler2000/Coeus
 def _terminal_condition(board: SearchBoard) -> bool:
     return board.is_fifty_moves() or board.is_insufficient_material() or \
            board.is_repetition(count=2) or board.is_repetition(count=4)
コード例 #17
0
ファイル: engine.py プロジェクト: dsiegler2000/Coeus
 def new_game(self):
     """
     Resets everything to be ready for a new game.
     :return: None
     """
     self.board = SearchBoard()
コード例 #18
0
ファイル: engine.py プロジェクト: dsiegler2000/Coeus
class CoeusEngine:
    """
    The engine largely just wraps the searcher to provide proper UCI protocol control and time control.
    This includes managing pondering.
    In order to use the engine, follow these steps:
    `engine.clear_mode()`
    `# set relevant engine parameters such as fixed_time, winc, etc.`
    `engine.set_mode(relevant mode booleans)`
    `engine.go(on_completed)`
    """
    def __init__(self, config_filepath: str, log_func: Callable[[str], None]):
        """
        Creates an engine using the given searcher.
        :param config_filepath: File path to the config file
        :param log_func: The function to use to log data
        """
        self.config_filepath = config_filepath
        self.board: SearchBoard = SearchBoard()

        with open(self.config_filepath, "r+") as config_f:
            self.config = json.load(config_f)

        self._parse_config()

        # Stop flag (for pondering)
        self._stop = False

        # Logging information
        self.log_func: Callable[[str], None] = log_func
        self._time_last_log: Optional[float] = None
        self._nodes_last_log: Optional[int] = None

    def _parse_config(self):
        self.name = self.config["name"]
        self.version = self.config["version"]

        # Evaluator and searcher
        self.evaluator = evaluation.evaluator_from_config(
            self.config["evaluator_config"])
        self.searcher = AlphaBetaMinimaxSearcher(
            self.config["searcher_config"], self.evaluator)

        # Ponder settings
        self._base_ponder_depth = self.config["base_ponder_depth"]
        self._ponder_pv_depth_offset = self.config["ponder_pv_depth_offset"]

    def _reset_logging(self):
        self._time_last_log = None
        self._nodes_last_log = None

    def _log_info(self):
        # Depth, nodes, and time
        elapsed_search_time_ms = int(
            1000 * (time.time() - self.searcher.search_info.start_time))
        self.log_func(f"info depth {self.searcher.search_info.curr_depth} "
                      f"nodes {self.searcher.search_info.nodes} "
                      f"time {elapsed_search_time_ms}")

        # Nodes per second
        if self._time_last_log and self._nodes_last_log:
            elapsed_time = time.time() - self._time_last_log
            nodes_since_last_log = self.searcher.search_info.nodes - self._nodes_last_log
            nps = nodes_since_last_log / elapsed_time
            self.log_func(f"info nps {nps}")

        self._time_last_log = time.time()
        self._nodes_last_log = self.searcher.search_info.nodes

    def _log_completed_depth(self,
                             depth_completed: Optional[int],
                             best_score: Optional[int],
                             pv_line: Optional[List[chess.Move]],
                             info_str: str = None):
        if depth_completed is not None and best_score is not None and pv_line is not None and len(
                pv_line) > 0:
            # Log the best score, depth, nodes, time, and PV line
            elapsed_search_time_ms = int(
                1000 * (time.time() - self.searcher.search_info.start_time))
            pv_line_str = " ".join(str(m) for m in pv_line)
            mate_in = self.searcher.mate_in(best_score)
            score_str = f"mate {mate_in}" if mate_in else f"cp {best_score}"
            self.log_func(f"info score {score_str} "
                          f"depth {depth_completed} "
                          f"nodes {self.searcher.search_info.nodes} "
                          f"time {elapsed_search_time_ms} "
                          f"pv {pv_line_str}")
        if info_str:
            self.log_func(f"info string {info_str}")

    def set_position(self, lag: List[str], starting_fen=chess.STARTING_FEN):
        """
        Sets the internal board state to that specified by the series of long algebraic notation moves given
        (starting from the specified start position).
        :param lag: List of long algebraic notation moves
        :param starting_fen: The FEN to start the board at
        :return: None
        """
        self.board = SearchBoard(fen=starting_fen)
        for move in lag:
            self.board.push(chess.Move.from_uci(move))

    def new_game(self):
        """
        Resets everything to be ready for a new game.
        :return: None
        """
        self.board = SearchBoard()

    def stop(self):
        """
        Stops the engine from the current search.
        :return: None
        """
        self._stop = True
        self.searcher.search_info.stop = True

    def go(self,
           params: EngineGoParams,
           on_completed: Callable[[List[chess.Move]], Any] = None,
           log_time_quantum: float = 0.25,
           ponder: bool = False) -> List[chess.Move]:
        """
        Finds the best move considering the time constraints and returns it.
        :param params: All parameters associated with a call to go, including the proper mode information
        :param on_completed: The completion callback (argument is the output)
        :param log_time_quantum: The time quantum that searching info will be logged every, in seconds
        :param ponder: Whether to ponder
        :return: The principal variation line found
        """
        logger.debug(f"Searching from board:\n")
        logger.debug(f"\n{str(self.board)}")
        logger.debug(f"FEN: {self.board.fen()}")
        logger.debug(f"mode={params.mode}")
        self.log_func(
            f"info string {self.board.transposition_table_size()} transposition table entries"
        )
        if params.mode == _TimeMode.INFINITE or ponder:
            fixed_time = None
            depth = None
            nodes = None
        elif params.mode == _TimeMode.TIME_CONTROL:
            moves_to_go = 30 if params.moves_to_go is None else params.moves_to_go
            time_left = params.wtime if self.board.turn == chess.WHITE else params.btime
            inc = params.winc if self.board.turn == chess.WHITE else params.binc
            if inc is None:
                inc = 0

            # Time control calculation
            fixed_time = ((time_left // moves_to_go) + inc) / 1_000
            depth = self.searcher.max_depth
            nodes = None
        elif params.mode == _TimeMode.FIXED_TIME:
            fixed_time = params.fixed_time / 1_000
            depth = self.searcher.max_depth
            nodes = None
        elif params.mode == _TimeMode.DEPTH:
            fixed_time = None
            depth = params.target_depth
            nodes = None
        elif params.mode == _TimeMode.NODES:
            fixed_time = None
            depth = None
            nodes = params.target_nodes
        else:
            raise ValueError("The mode is not set properly!")

        # Set a separate timer to log information
        self._reset_logging()
        if ponder and abs(log_time_quantum - 0.25) < 1e-3:
            log_time_quantum = 10.0
        timer = call_repeatedly(log_time_quantum, self._log_info)

        if ponder:
            # In ponder mode, use a kind of iterative deepening, namely:
            # - Search to a base depth plus an offset for the opponent's PV move
            # - Search to a base depth for all other of the opponent's moves
            # - Increase the base depth and repeat
            pondering_board: SearchBoard = self.board.copy_transposition_table_referenced(
            )

            # Recall that this PV line is dynamically updated so we simply take the 1st element
            pv_line = pondering_board.generate_pv_line(depth=1)
            # Note that in theory the PV line could be messed up so verify that the entry is actual a legal move
            suspected_opponent_pv_move = pv_line[0] if len(
                pv_line) > 0 else None
            opponent_pv_move = None
            other_opponent_moves = []
            for move in pondering_board.legal_moves:
                if suspected_opponent_pv_move == move:
                    opponent_pv_move = suspected_opponent_pv_move
                else:
                    other_opponent_moves.append(move)
            ponder_depth = self._base_ponder_depth
            kwargs = {
                "on_depth_completed": self._log_completed_depth,
                "log_func": lambda s: self.log_func(f"info string {s}"),
                "use_opening_book": False,
                "use_endgame_tablebase": False
            }
            while not self._stop and ponder_depth + self._ponder_pv_depth_offset < self.searcher.max_depth:
                if opponent_pv_move:
                    if self._stop:
                        self.stop()
                        break
                    self._reset_logging()
                    pondering_board.push(opponent_pv_move)
                    self.searcher.search(
                        pondering_board, None,
                        ponder_depth + self._ponder_pv_depth_offset, None,
                        **kwargs)
                    pondering_board.pop()
                for move in other_opponent_moves:
                    if self._stop:
                        self.stop()
                        break
                    self._reset_logging()
                    pondering_board.push(move)
                    self.searcher.search(pondering_board, None, ponder_depth,
                                         None, **kwargs)
                    pondering_board.pop()
                ponder_depth += 1
            # Simply set for returning
            pv_line = None
        else:
            pv_line = self.searcher.search(
                self.board,
                fixed_time,
                depth,
                nodes,
                on_depth_completed=self._log_completed_depth,
                log_func=lambda s: self.log_func(f"info string {s}"),
                use_opening_book=params.use_opening_book,
                use_endgame_tablebase=params.use_endgame_tablebase)

        # Stop the logging timer
        timer()

        # Reset stop flag
        self._stop = False

        if on_completed:
            on_completed(pv_line)

        return pv_line