diff --git a/.gitignore b/.gitignore index f274de8..9a3f603 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,6 @@ */__pycache__ */*/__pycache__ coverage.xml -.coverage +.coverage* docs/ .vscode/ diff --git a/README.md b/README.md index 77efddd..8ce7536 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ > [!NOTE] > This section refers to the technical application. If you are looking for information regarding the status of this project and the original repo, please look [here](https://github.com/py-stockfish/stockfish/tree/master#status-of-the-project). -Implements an easy-to-use Stockfish class to integrates the Stockfish chess engine with Python. +Wraps the open-source Stockfish chess engine for easy integration into python. ## Install @@ -79,6 +79,12 @@ These parameters can also be updated at any time by calling the "update_engine_p stockfish.update_engine_parameters({"Hash": 2048, "UCI_Chess960": True}) # Gets stockfish to use a 2GB hash table, and also to play Chess960. ``` +As for the depth, it can also be updated, by using the following function. Note that if you don't set depth to a value yourself, the python module will initialize it to 15 by default. + +```python +stockfish.set_depth(12) +``` + When you're done using the Stockfish engine process, you can send the "quit" uci command to it with: ```python @@ -90,19 +96,25 @@ The `__del__()` method of the Stockfish class will call send_quit_command(), but ### Set position by a sequence of moves from the starting position ```python -stockfish.set_position(["e2e4", "e7e6"]) +stockfish.make_moves_from_start(["e2e4", "e7e6"]) +``` +If you'd just like to set up the starting position without making any moves from it, just call this function without sending an argument: +```python +stockfish.make_moves_from_start() ``` ### Update position by making a sequence of moves from the current position +Function takes a list of strings as its argument. Each string represents a move, and must have the format of the starting coordinate followed by the ending coordinate. If a move leads to a pawn promoting, then an additional character must be appended at the end (to indicate what piece the pawn promotes into). +Other types of special moves (e.g., checks, captures, checkmates, en passants) do not need any special notation; the starting coordinate followed by the ending coordinate is all the information that's needed. Note that castling is represented by the starting coordinate of the king followed by the ending coordinate of the king. So "e1g1" would be used for white castling kingside, assuming the white king is still on e1 and castling is legal. +Example call (assume in the current position, it is White's turn): ```python -stockfish.make_moves_from_current_position(["g4d7", "a8b8", "f1d1"]) +stockfish.make_moves_from_current_position(["g4d7", "a8b8", "f1d1", "b2b1q"]) # Moves the white piece on g4 to d7, then the black piece on a8 to b8, then the white piece on f1 to d1, and finally pushes the black b2-pawn to b1, promoting it into a queen. ``` ### Set position by Forsyth–Edwards Notation (FEN) -If you'd like to first check if your fen is valid, call the is_fen_valid() function below. -Also, if you want to play Chess960, it's recommended you first update the "UCI_Chess960" engine parameter to be True, before calling set_fen_position. +Note that if you want to play Chess960, it's recommended you first update the "UCI_Chess960" engine parameter to be True, before calling set_fen_position. ```python stockfish.set_fen_position("rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2") @@ -115,6 +127,10 @@ The function isn't perfect and won't catch all cases, but generally it should re For example, one exception is positions which are legal, but have no legal moves. I.e., for checkmates and stalemates, this function will incorrectly say the fen is invalid. +Note that the function checks whether a position is legal by temporarily creating a new Stockfish process, and +then seeing if it can return a best move (and also not crash). Whatever the outcome may be though, this +temporary SF process should terminate after the function call. + ```python stockfish.is_fen_valid("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") ``` @@ -161,6 +177,8 @@ e2e4 ### Check if a move is legal in the current position +Returns True if the passed in move is legal in the current position. + ```python stockfish.is_move_correct('a2a3') ``` @@ -171,15 +189,21 @@ True ### Get info on the top n moves -Get moves, centipawns, and mates for the top n moves. If the move is a mate, the Centipawn value will be None, and vice versa. Note that if you have stockfish on a weaker elo or skill level setting, the top moves returned by this -function will still be for full strength. +Returns a list of dictionaries, where each dictionary represents a move's info. Each dictionary will contain a value for the 'Move' key, and either the 'Centipawn' or 'Mate' value will be a number (the other will be None). +Positive values mean advantage white, negative advantage black (unless you're using the turn perspective setting). + +Positive values mean advantage White, negative values mean advantage Black (unless you're using the turn perspective option, in which case positive is for the side to move). + +Note that if you have stockfish on a weaker elo or skill level setting, the top moves returned by this function will still be for full strength. + +Let's consider an example where Black is to move, and the top 3 moves are a mate, winning material, or being slightly worse. We'll assume the turn perspective setting is off. ```python stockfish.get_top_moves(3) # [ -# {'Move': 'f5h7', 'Centipawn': None, 'Mate': 1}, -# {'Move': 'f5d7', 'Centipawn': 713, 'Mate': None}, -# {'Move': 'f5h5', 'Centipawn': -31, 'Mate': None} +# {'Move': 'f5h3', 'Centipawn': None, 'Mate': -1}, # The move f5h3 leads to a mate in 1 for Black. +# {'Move': 'f5d7', 'Centipawn': -713, 'Mate': None}, # f5d7 leads to an evaluation of 7.13 in Black's favour. +# {'Move': 'f5h5', 'Centipawn': 31, 'Mate': None} # f5h5 leads to an evaluation of 0.31 in White's favour. # ] ``` @@ -285,7 +309,7 @@ stockfish.set_elo_rating(1350) stockfish.resume_full_strength() ``` -### Set the engine's depth +### Set the engine's search depth ```python stockfish.set_depth(15) @@ -485,17 +509,30 @@ stockfish.is_development_build_of_engine() False ``` +### Send the "ucinewgame" command to the Stockfish engine process. +This command will clear Stockfish's hash table, which is relatively expensive and should generally only be done if the new position will be completely unrelated to the current one (such as a new game). +```python +stockfish.send_ucinewgame_command() +``` + ### Find what is on a certain square If the square is empty, the None object is returned. Otherwise, one of 12 enum members of a custom Stockfish.Piece enum will be returned. Each of the 12 members of this enum is named in the following pattern: _colour_ followed by _underscore_ followed by _piece name_, where the colour and piece name are in all caps. +The value of each enum member is a char representing the piece (uppercase is white, lowercase is black). +For white, it will be one of "P", "N", "B", "R", "Q", or "K". For black the same chars, except lowercase. For example, say the current position is the starting position: ```python stockfish.get_what_is_on_square("e1") # returns Stockfish.Piece.WHITE_KING +stockfish.get_what_is_on_square("e1").value # result is "K" stockfish.get_what_is_on_square("d8") # returns Stockfish.Piece.BLACK_QUEEN +stockfish.get_what_is_on_square("d8").value # result is "q" stockfish.get_what_is_on_square("h2") # returns Stockfish.Piece.WHITE_PAWN +stockfish.get_what_is_on_square("h2").value # result is "P" +stockfish.get_what_is_on_square("g8") # returns Stockfish.Piece.BLACK_KNIGHT +stockfish.get_what_is_on_square("g8").value # result is "n" stockfish.get_what_is_on_square("b5") # returns None ``` diff --git a/stockfish/models.py b/stockfish/models.py index e47ee62..e57866f 100644 --- a/stockfish/models.py +++ b/stockfish/models.py @@ -15,6 +15,7 @@ import re import datetime import warnings +import platform class Stockfish: @@ -42,7 +43,7 @@ class Stockfish: _PARAM_RESTRICTIONS: Dict[str, Tuple[type, Optional[int], Optional[int]]] = { "Debug Log File": (str, None, None), "Threads": (int, 1, 1024), - "Hash": (int, 1, 2048), + "Hash": (int, 1, 2 ** (25 if "64" in platform.machine() else 11)), "Ponder": (bool, None, None), "MultiPV": (int, 1, 500), "Skill Level": (int, 0, 20), @@ -118,7 +119,8 @@ def __init__( if self.does_current_engine_version_have_wdl_option(): self._set_option("UCI_ShowWDL", True, False) - self._prepare_for_new_position(True) + self._prepare_for_new_position() + self._is_ready() def set_debug_view(self, activate: bool) -> None: self._debug_view = activate @@ -199,7 +201,7 @@ def update_engine_parameters(self, parameters: Optional[dict]) -> None: for name, value in new_param_values.items(): self._set_option(name, value) - self.set_fen_position(self.get_fen_position(), False) + self.set_fen_position(self.get_fen_position()) # Getting SF to set the position again, since UCI option(s) have been updated. def reset_engine_parameters(self) -> None: @@ -210,16 +212,26 @@ def reset_engine_parameters(self) -> None: """ self.update_engine_parameters(self._DEFAULT_STOCKFISH_PARAMS) - def _prepare_for_new_position(self, send_ucinewgame_token: bool = True) -> None: - if send_ucinewgame_token: + def send_ucinewgame_command(self) -> None: + """Sends the `ucinewgame` command to the Stockfish engine. This will clear Stockfish's + hash table, which is relatively expensive and should generally only be done if the + new position will be completely unrelated to the current one (such as a new game). + """ + if self._stockfish.poll() is None: self._put("ucinewgame") - self._is_ready() + self._is_ready() + + def _prepare_for_new_position(self) -> None: self.info = "" def _put(self, command: str) -> None: + """Sends a command to the Stockfish engine. Note that this function shouldn't be called if + there's any existing output in stdout that's still needed.""" if not self._stockfish.stdin: raise BrokenPipeError() if self._stockfish.poll() is None and not self._has_quit_command_been_sent: + if command != "isready": + self._is_ready() if self._debug_view: print(f">>> {command}\n") self._stockfish.stdin.write(f"{command}\n") @@ -266,6 +278,8 @@ def _validate_param_val(self, name: str, value: Any) -> None: raise ValueError(f"{value} is over {name}'s maximum value of {maximum}") def _is_ready(self) -> None: + """Waits if the engine is busy. Note that this function shouldn't be called if + there's any existing output in stdout that's still needed.""" self._put("isready") while self._read_line() != "readyok": pass @@ -300,31 +314,24 @@ def _weaker_setting_warning(self, message: str) -> None: """Will issue a warning, referring to the function that calls this one.""" warnings.warn(message, stacklevel=3) - def set_fen_position( - self, fen_position: str, send_ucinewgame_token: bool = True - ) -> None: - """Sets current board position in Forsyth-Edwards notation (FEN). + def set_fen_position(self, fen_position: str) -> None: + """Sets the current board position from Forsyth-Edwards notation (FEN). Args: fen_position: FEN string of board position. - send_ucinewgame_token: - Whether to send the `ucinewgame` token to the Stockfish engine. - The most prominent effect this will have is clearing Stockfish's transposition table, - which should be done if the new position is unrelated to the current position. - Returns: `None` Example: >>> stockfish.set_fen_position("1nb1k1n1/pppppppp/8/6r1/5bqK/6r1/8/8 w - - 2 2") """ - self._prepare_for_new_position(send_ucinewgame_token) + self._prepare_for_new_position() self._put(f"position fen {fen_position}") - def set_position(self, moves: Optional[List[str]] = None) -> None: - """Sets current board position. + def make_moves_from_start(self, moves: Optional[List[str]] = None) -> None: + """Sets the position by making a sequence of moves from the starting position of chess. Args: moves: @@ -335,10 +342,10 @@ def set_position(self, moves: Optional[List[str]] = None) -> None: `None` Example: - >>> stockfish.set_position(['e2e4', 'e7e5']) + >>> stockfish.make_moves_from_start(['e2e4', 'e7e5']) """ self.set_fen_position( - "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", True + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" ) self.make_moves_from_current_position(moves) @@ -358,11 +365,8 @@ def make_moves_from_current_position(self, moves: Optional[List[str]]) -> None: """ if not moves: return - self._prepare_for_new_position(False) - for move in moves: - if not self.is_move_correct(move): - raise ValueError(f"Cannot make move: {move}") - self._put(f"position fen {self.get_fen_position()} moves {move}") + self._prepare_for_new_position() + self._put(f"position fen {self.get_fen_position()} moves {' '.join(moves)}") def get_board_visual(self, perspective_white: bool = True) -> str: """Returns a visual representation of the current board position. @@ -639,14 +643,12 @@ def _is_fen_syntax_valid(fen: str) -> bool: fen_fields = fen.split() - if any( - ( - len(fen_fields) != 6, - len(fen_fields[0].split("/")) != 8, - any(x not in fen_fields[0] for x in "Kk"), - any(not fen_fields[x].isdigit() for x in (4, 5)), - int(fen_fields[4]) >= int(fen_fields[5]) * 2, - ) + if ( + len(fen_fields) != 6 + or len(fen_fields[0].split("/")) != 8 + or any(x not in fen_fields[0] for x in "Kk") + or any(not fen_fields[x].isdigit() for x in (4, 5)) + or int(fen_fields[4]) >= int(fen_fields[5]) * 2 ): return False @@ -683,7 +685,7 @@ def is_fen_valid(self, fen: str) -> bool: # Using a new temporary SF instance, in case the fen is an illegal position that causes # the SF process to crash. best_move: Optional[str] = None - temp_sf.set_fen_position(fen, False) + temp_sf.set_fen_position(fen) try: temp_sf._put("go depth 10") best_move = temp_sf._get_best_move_from_sf_popen_process() @@ -768,7 +770,7 @@ def does_current_engine_version_have_wdl_option(self) -> bool: splitted_text = self._read_line().split(" ") if splitted_text[0] == "uciok": return False - elif "UCI_ShowWDL" in splitted_text: + if "UCI_ShowWDL" in splitted_text: self._discard_remaining_stdout_lines("uciok") return True @@ -836,10 +838,10 @@ def get_static_eval(self) -> Optional[float]: self._read_line() # Consume the remaining line (for some reason `eval` outputs an extra newline) if static_eval == "none": - assert "(in check)" in text + if "(in check)" not in text: + raise RuntimeError() return None - else: - return float(static_eval) * compare + return float(static_eval) * compare def get_top_moves( self, @@ -1012,7 +1014,8 @@ def get_perft(self, depth: int) -> Tuple[int, dict[str, int]]: num_nodes = int(line.split(":")[1]) break move, num = line.split(":") - assert move not in move_possibilities + if move in move_possibilities: + raise RuntimeError() move_possibilities[move] = int(num) self._read_line() # Consumes the remaining newline stockfish outputs. @@ -1085,25 +1088,22 @@ def will_move_be_a_capture(self, move_value: str) -> Capture: if ending_square_piece is not None: if not self._parameters["UCI_Chess960"]: return Stockfish.Capture.DIRECT_CAPTURE - else: - # Check for Chess960 castling: - castling_pieces = [ - [Stockfish.Piece.WHITE_KING, Stockfish.Piece.WHITE_ROOK], - [Stockfish.Piece.BLACK_KING, Stockfish.Piece.BLACK_ROOK], - ] - if [starting_square_piece, ending_square_piece] in castling_pieces: - return Stockfish.Capture.NO_CAPTURE - else: - return Stockfish.Capture.DIRECT_CAPTURE - elif move_value[2:4] == self.get_fen_position().split()[ + # Check for Chess960 castling: + castling_pieces = [ + [Stockfish.Piece.WHITE_KING, Stockfish.Piece.WHITE_ROOK], + [Stockfish.Piece.BLACK_KING, Stockfish.Piece.BLACK_ROOK], + ] + if [starting_square_piece, ending_square_piece] in castling_pieces: + return Stockfish.Capture.NO_CAPTURE + return Stockfish.Capture.DIRECT_CAPTURE + if move_value[2:4] == self.get_fen_position().split()[ 3 ] and starting_square_piece in [ Stockfish.Piece.WHITE_PAWN, Stockfish.Piece.BLACK_PAWN, ]: return Stockfish.Capture.EN_PASSANT - else: - return Stockfish.Capture.NO_CAPTURE + return Stockfish.Capture.NO_CAPTURE def get_stockfish_full_version(self) -> float: """Returns Stockfish engine full version.""" @@ -1196,7 +1196,7 @@ def _parse_stockfish_version(self, version_text: str = "") -> None: except Exception as e: raise Exception( "Unable to parse Stockfish version. You may be using an unsupported version of Stockfish." - ) + ) from e def _get_stockfish_version_from_build_date( self, date_string: str = "" diff --git a/tests/stockfish/test_models.py b/tests/stockfish/test_models.py index 93a7467..50e5faf 100644 --- a/tests/stockfish/test_models.py +++ b/tests/stockfish/test_models.py @@ -1,11 +1,19 @@ import pytest from timeit import default_timer import time -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Callable, Type, Any +import platform +import operator from stockfish import Stockfish, StockfishException +def compare(first, second, op: Callable[[Any, Any], bool], expected_type: Type) -> bool: + return all(isinstance(x, expected_type) for x in (first, second)) and op( + first, second + ) + + class TestStockfish: @pytest.fixture def stockfish(self) -> Stockfish: @@ -63,45 +71,45 @@ def test_get_best_move_remaining_time_first_move(self, stockfish: Stockfish): best_move = stockfish.get_best_move(wtime=5 * 60 * 1000, btime=1000) assert best_move in ("e2e3", "e2e4", "g1f3", "b1c3", "d2d4") - def test_set_position_resets_info(self, stockfish: Stockfish): - stockfish.set_position(["e2e4", "e7e6"]) + def test_make_moves_from_start_resets_info(self, stockfish: Stockfish): + stockfish.make_moves_from_start(["e2e4", "e7e6"]) stockfish.get_best_move() assert stockfish.info != "" - stockfish.set_position(["e2e4", "e7e6"]) + stockfish.make_moves_from_start(["e2e4", "e7e6"]) assert stockfish.info == "" def test_get_best_move_not_first_move(self, stockfish: Stockfish): - stockfish.set_position(["e2e4", "e7e6"]) + stockfish.make_moves_from_start(["e2e4", "e7e6"]) best_move = stockfish.get_best_move() assert best_move in ("d2d4", "g1f3") def test_get_best_move_time_not_first_move(self, stockfish: Stockfish): - stockfish.set_position(["e2e4", "e7e6"]) + stockfish.make_moves_from_start(["e2e4", "e7e6"]) best_move = stockfish.get_best_move_time(1000) assert best_move in ("d2d4", "g1f3") @pytest.mark.slow def test_get_best_move_remaining_time_not_first_move(self, stockfish: Stockfish): - stockfish.set_position(["e2e4", "e7e6"]) + stockfish.make_moves_from_start(["e2e4", "e7e6"]) best_move = stockfish.get_best_move(wtime=1000) assert best_move in ("d2d4", "a2a3", "d1e2", "b1c3") best_move = stockfish.get_best_move(btime=1000) - assert best_move in ("d2d4", "b1c3") + assert best_move in ("d2d4", "b1c3", "g1f3") best_move = stockfish.get_best_move(wtime=1000, btime=1000) assert best_move in ("d2d4", "b1c3", "g1f3") best_move = stockfish.get_best_move(wtime=5 * 60 * 1000, btime=1000) assert best_move in ("e2e3", "e2e4", "g1f3", "b1c3", "d2d4") def test_get_best_move_checkmate(self, stockfish: Stockfish): - stockfish.set_position(["f2f3", "e7e5", "g2g4", "d8h4"]) + stockfish.make_moves_from_start(["f2f3", "e7e5", "g2g4", "d8h4"]) assert stockfish.get_best_move() is None def test_get_best_move_time_checkmate(self, stockfish: Stockfish): - stockfish.set_position(["f2f3", "e7e5", "g2g4", "d8h4"]) + stockfish.make_moves_from_start(["f2f3", "e7e5", "g2g4", "d8h4"]) assert stockfish.get_best_move_time(1000) is None def test_get_best_move_remaining_time_checkmate(self, stockfish: Stockfish): - stockfish.set_position(["f2f3", "e7e5", "g2g4", "d8h4"]) + stockfish.make_moves_from_start(["f2f3", "e7e5", "g2g4", "d8h4"]) assert stockfish.get_best_move(wtime=1000) is None assert stockfish.get_best_move(btime=1000) is None assert stockfish.get_best_move(wtime=1000, btime=1000) is None @@ -127,39 +135,45 @@ def test_set_fen_position_mate(self, stockfish: Stockfish): assert stockfish.info == "info depth 0 score mate 0" def test_clear_info_after_set_new_fen_position(self, stockfish: Stockfish): + stockfish.send_ucinewgame_command() stockfish.set_fen_position("8/8/8/6pp/8/4k1PP/r7/4K3 b - - 11 52") stockfish.get_best_move() + stockfish.send_ucinewgame_command() stockfish.set_fen_position("8/8/8/6pp/8/4k1PP/8/r3K3 w - - 12 53") assert stockfish.info == "" + stockfish.send_ucinewgame_command() stockfish.set_fen_position("8/8/8/6pp/8/4k1PP/r7/4K3 b - - 11 52") stockfish.get_best_move() - stockfish.set_fen_position("8/8/8/6pp/8/4k1PP/8/r3K3 w - - 12 53", False) + stockfish.set_fen_position("8/8/8/6pp/8/4k1PP/8/r3K3 w - - 12 53") assert stockfish.info == "" def test_set_fen_position_starts_new_game(self, stockfish: Stockfish): + stockfish.send_ucinewgame_command() stockfish.set_fen_position( "7r/1pr1kppb/2n1p2p/2NpP2P/5PP1/1P6/P6K/R1R2B2 w - - 1 27" ) stockfish.get_best_move() assert stockfish.info != "" + stockfish.send_ucinewgame_command() stockfish.set_fen_position("3kn3/p5rp/1p3p2/3B4/3P1P2/2P5/1P3K2/8 w - - 0 53") assert stockfish.info == "" def test_set_fen_position_second_argument(self, stockfish: Stockfish): stockfish.set_depth(16) + stockfish.send_ucinewgame_command() stockfish.set_fen_position( - "rnbqk2r/pppp1ppp/3bpn2/8/3PP3/2N5/PPP2PPP/R1BQKBNR w KQkq - 0 1", True + "rnbqk2r/pppp1ppp/3bpn2/8/3PP3/2N5/PPP2PPP/R1BQKBNR w KQkq - 0 1" ) assert stockfish.get_best_move() == "e4e5" stockfish.set_fen_position( - "rnbqk2r/pppp1ppp/3bpn2/4P3/3P4/2N5/PPP2PPP/R1BQKBNR b KQkq - 0 1", False + "rnbqk2r/pppp1ppp/3bpn2/4P3/3P4/2N5/PPP2PPP/R1BQKBNR b KQkq - 0 1" ) assert stockfish.get_best_move() in ("d6e7", "d6b4") stockfish.set_fen_position( - "rnbqk2r/pppp1ppp/3bpn2/8/3PP3/2N5/PPP2PPP/R1BQKBNR w KQkq - 0 1", False + "rnbqk2r/pppp1ppp/3bpn2/8/3PP3/2N5/PPP2PPP/R1BQKBNR w KQkq - 0 1" ) assert stockfish.get_best_move() == "e4e5" @@ -168,7 +182,7 @@ def test_is_move_correct_first_move(self, stockfish: Stockfish): assert stockfish.is_move_correct("a2a3") is True def test_is_move_correct_not_first_move(self, stockfish: Stockfish): - stockfish.set_position(["e2e4", "e7e6"]) + stockfish.make_moves_from_start(["e2e4", "e7e6"]) assert stockfish.is_move_correct("e2e1") is False assert stockfish.is_move_correct("a2a3") is True @@ -182,6 +196,7 @@ def test_is_move_correct_not_first_move(self, stockfish: Stockfish): ) # fmt: on def test_last_info(self, stockfish: Stockfish, value): + stockfish.send_ucinewgame_command() stockfish.set_fen_position("r6k/6b1/2b1Q3/p6p/1p5q/3P2PP/5r1K/8 w - - 1 31") stockfish.get_best_move() assert value in stockfish.info @@ -200,13 +215,13 @@ def test_set_skill_level(self, stockfish: Stockfish): ) # fmt: on assert stockfish.get_engine_parameters()["Skill Level"] == 1 - assert stockfish.get_engine_parameters()["UCI_LimitStrength"] == False + assert stockfish.get_engine_parameters()["UCI_LimitStrength"] is False assert stockfish._on_weaker_setting() stockfish.set_skill_level(20) assert stockfish.get_best_move() in ("d2d4", "c2c4") assert stockfish.get_engine_parameters()["Skill Level"] == 20 - assert stockfish.get_engine_parameters()["UCI_LimitStrength"] == False + assert stockfish.get_engine_parameters()["UCI_LimitStrength"] is False assert not stockfish._on_weaker_setting() def test_set_elo_rating(self, stockfish: Stockfish): @@ -220,11 +235,11 @@ def test_set_elo_rating(self, stockfish: Stockfish): stockfish.set_elo_rating(2000) # fmt: off assert stockfish.get_best_move() in ( - "d2d4", "b1c3", "d1e2", "c2c4", "f1e2", "h2h3", "c2c3", "f1d3", "a2a3" + "d2d4", "b1c3", "d1e2", "c2c4", "f1e2", "h2h3", "c2c3", "f1d3", "a2a3", "a2a4" ) # fmt: on assert stockfish.get_engine_parameters()["UCI_Elo"] == 2000 - assert stockfish.get_engine_parameters()["UCI_LimitStrength"] == True + assert stockfish.get_engine_parameters()["UCI_LimitStrength"] is True assert stockfish._on_weaker_setting() stockfish.set_elo_rating(1350) @@ -234,7 +249,7 @@ def test_set_elo_rating(self, stockfish: Stockfish): ) # fmt: on assert stockfish.get_engine_parameters()["UCI_Elo"] == 1350 - assert stockfish.get_engine_parameters()["UCI_LimitStrength"] == True + assert stockfish.get_engine_parameters()["UCI_LimitStrength"] is True assert stockfish._on_weaker_setting() stockfish.set_elo_rating(2850) @@ -302,13 +317,14 @@ def test_update_engine_parameters_wrong_vals(self, stockfish: Stockfish): assert set(stockfish.get_engine_parameters().keys()) <= set( Stockfish._PARAM_RESTRICTIONS.keys() ) + max_hash = 2 ** (25 if "64" in platform.machine() else 11) bad_values: Dict[str, List] = { "Threads": ["1", False, 0, -1, 1025, 1.0], "UCI_Chess960": ["true", "false", "True", 1], "Contempt": [-101, 101, "0", False], "UCI_LimitStrength": ["true", "false", "False", 1, 0], "Ponder": ["true", "false", "True", "False", 0], - "Hash": [-1, 4096, -2048, True, 0], + "Hash": [-1, max_hash * 2, max_hash + 1, -2048, True, 0], "Not key": [0], } for name in bad_values: @@ -350,7 +366,7 @@ def test_chess960_position(self, stockfish: Stockfish): assert stockfish.will_move_be_a_capture("f1g1") is Stockfish.Capture.NO_CAPTURE def test_get_board_visual_white(self, stockfish: Stockfish): - stockfish.set_position(["e2e4", "e7e6", "d2d4", "d7d5"]) + stockfish.make_moves_from_start(["e2e4", "e7e6", "d2d4", "d7d5"]) if stockfish.get_stockfish_major_version() >= 12: expected_result = ( "+---+---+---+---+---+---+---+---+\n" @@ -402,7 +418,7 @@ def test_get_board_visual_white(self, stockfish: Stockfish): # the second line read after stockfish._put("d") now will be the +---+---+---+ of the new outputted board. def test_get_board_visual_black(self, stockfish: Stockfish): - stockfish.set_position(["e2e4", "e7e6", "d2d4", "d7d5"]) + stockfish.make_moves_from_start(["e2e4", "e7e6", "d2d4", "d7d5"]) if stockfish.get_stockfish_major_version() >= 12: expected_result = ( "+---+---+---+---+---+---+---+---+\n" @@ -463,7 +479,7 @@ def test_get_fen_position(self, stockfish: Stockfish): assert "+---+---+---+" in stockfish._read_line() def test_get_fen_position_after_some_moves(self, stockfish: Stockfish): - stockfish.set_position(["e2e4", "e7e6"]) + stockfish.make_moves_from_start(["e2e4", "e7e6"]) assert ( stockfish.get_fen_position() == "rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2" @@ -477,19 +493,17 @@ def test_get_evaluation_cp(self, stockfish: Stockfish): ) evaluation = stockfish.get_evaluation() assert ( - evaluation["type"] == "cp" - and isinstance(evaluation["value"], int) - and evaluation["value"] >= 60 - and evaluation["value"] <= 150 + compare(evaluation["type"], "cp", operator.eq, str) + and compare(evaluation["value"], 60, operator.ge, int) + and compare(evaluation["value"], 150, operator.le, int) ) stockfish.set_skill_level(1) with pytest.warns(UserWarning): evaluation = stockfish.get_evaluation() assert ( - evaluation["type"] == "cp" - and isinstance(evaluation["value"], int) - and evaluation["value"] >= 60 - and evaluation["value"] <= 150 + compare(evaluation["type"], "cp", operator.eq, str) + and compare(evaluation["value"], 60, operator.ge, int) + and compare(evaluation["value"], 150, operator.le, int) ) @pytest.mark.slow @@ -516,22 +530,23 @@ def test_get_evaluation_stalemate(self, stockfish: Stockfish): def test_get_static_eval(self, stockfish: Stockfish): stockfish.set_turn_perspective(False) + stockfish.send_ucinewgame_command() stockfish.set_fen_position("r7/8/8/8/8/5k2/4p3/4K3 w - - 0 1") - static_eval_1 = stockfish.get_static_eval() - assert isinstance(static_eval_1, float) and static_eval_1 < -3 + assert compare(stockfish.get_static_eval(), -3.0, operator.lt, float) + stockfish.send_ucinewgame_command() stockfish.set_fen_position("r7/8/8/8/8/5k2/4p3/4K3 b - - 0 1") - static_eval_2 = stockfish.get_static_eval() - assert isinstance(static_eval_2, float) and static_eval_2 < -3 + assert compare(stockfish.get_static_eval(), -3.0, operator.lt, float) stockfish.set_turn_perspective() - static_eval_3 = stockfish.get_static_eval() - assert isinstance(static_eval_3, float) and static_eval_3 > 3 + assert compare(stockfish.get_static_eval(), 3.0, operator.gt, float) + stockfish.send_ucinewgame_command() stockfish.set_fen_position("r7/8/8/8/8/5k2/4p3/4K3 w - - 0 1") - static_eval_4 = stockfish.get_static_eval() - assert isinstance(static_eval_4, float) and static_eval_4 < -3 + assert compare(stockfish.get_static_eval(), -3.0, operator.lt, float) if stockfish.get_stockfish_major_version() >= 12: + stockfish.send_ucinewgame_command() stockfish.set_fen_position("8/8/8/8/8/4k3/4p3/r3K3 w - - 0 1") assert stockfish.get_static_eval() is None - stockfish.set_position(None) + stockfish.send_ucinewgame_command() + stockfish.make_moves_from_start(None) stockfish.get_static_eval() stockfish._put("go depth 2") assert stockfish._read_line() != "" @@ -614,11 +629,11 @@ def test_constructor(self, stockfish: Stockfish): assert stockfish_2_params[key] == 2850 assert stockfish_1_params[key] == 1350 elif key == "UCI_LimitStrength": - assert stockfish_2_params[key] == True - assert stockfish_1_params[key] == False + assert stockfish_2_params[key] is True + assert stockfish_1_params[key] is False elif key == "UCI_Chess960": - assert stockfish_2_params[key] == True - assert stockfish_1_params[key] == False + assert stockfish_2_params[key] is True + assert stockfish_1_params[key] is False else: assert stockfish_2_params[key] == stockfish_1_params[key] @@ -648,18 +663,18 @@ def test_parameters_functions(self, stockfish: Stockfish): elif key == "MultiPV": assert value == 2 elif key == "UCI_Chess960": - assert value == True + assert value is True else: assert updated_parameters[key] == old_parameters[key] - assert stockfish.get_engine_parameters()["UCI_LimitStrength"] == False + assert stockfish.get_engine_parameters()["UCI_LimitStrength"] is False stockfish.update_engine_parameters({"UCI_Elo": 2000, "Skill Level": 19}) assert stockfish.get_engine_parameters()["UCI_Elo"] == 2000 assert stockfish.get_engine_parameters()["Skill Level"] == 19 - assert stockfish.get_engine_parameters()["UCI_LimitStrength"] == False + assert stockfish.get_engine_parameters()["UCI_LimitStrength"] is False stockfish.update_engine_parameters({"UCI_Elo": 2000}) - assert stockfish.get_engine_parameters()["UCI_LimitStrength"] == True + assert stockfish.get_engine_parameters()["UCI_LimitStrength"] is True stockfish.update_engine_parameters({"Skill Level": 20}) - assert stockfish.get_engine_parameters()["UCI_LimitStrength"] == False + assert stockfish.get_engine_parameters()["UCI_LimitStrength"] is False assert stockfish.get_fen_position() == "4rkr1/4p1p1/8/8/8/8/8/5K1R w H - 0 100" stockfish.reset_engine_parameters() assert stockfish.get_engine_parameters() == old_parameters @@ -796,14 +811,12 @@ def test_turn_perspective(self, stockfish: Stockfish): assert stockfish.get_turn_perspective() moves = stockfish.get_top_moves(1) assert moves[0]["Centipawn"] > 0 - eval = stockfish.get_evaluation()["value"] - assert isinstance(eval, int) and eval > 0 + assert compare(stockfish.get_evaluation()["value"], 0, operator.gt, int) stockfish.set_turn_perspective(False) assert stockfish.get_turn_perspective() is False moves = stockfish.get_top_moves(1) assert moves[0]["Centipawn"] < 0 - eval = stockfish.get_evaluation()["value"] - assert isinstance(eval, int) and eval < 0 + assert compare(stockfish.get_evaluation()["value"], 0, operator.lt, int) def test_turn_perspective_raises_type_error(self, stockfish: Stockfish): with pytest.raises(TypeError): @@ -845,22 +858,16 @@ def test_make_moves_from_current_position(self, stockfish: Stockfish): invalid_moves = ["d1e3", "e1g1", "c3d5", "c1d4", "a7a6", "e1d2", "word"] + current_fen = stockfish.get_fen_position() + stockfish.make_moves_from_current_position(invalid_moves) + assert current_fen == stockfish.get_fen_position() for invalid_move in invalid_moves: - with pytest.raises(ValueError): - stockfish.make_moves_from_current_position([invalid_move]) + stockfish.make_moves_from_current_position([invalid_move]) + assert current_fen == stockfish.get_fen_position() @pytest.mark.slow - def test_make_moves_transposition_table_speed(self, stockfish: Stockfish): - """ - make_moves_from_current_position won't send the "ucinewgame" token to Stockfish, since it - will reach a new position similar to the current one. Meanwhile, set_fen_position will send this - token (unless the user specifies otherwise), since it could be going to a completely new position. - - A big effect of sending this token is that it resets SF's transposition table. If the - new position is similar to the current one, this will affect SF's speed. This function tests - that make_moves_from_current_position doesn't reset the transposition table, by verifying SF is faster in - evaluating a consecutive set of positions when the make_moves_from_current_position function is used. - """ + def test_not_resetting_hash_table_speed(self, stockfish: Stockfish): + """Tests that not resetting the hash table between related positions makes SF faster.""" stockfish.set_depth(16) positions_considered = [] @@ -879,6 +886,7 @@ def test_make_moves_transposition_table_speed(self, stockfish: Stockfish): total_time_calculating_second = 0.0 for i in range(len(positions_considered)): + stockfish.send_ucinewgame_command() stockfish.set_fen_position(positions_considered[i]) start = default_timer() stockfish.get_best_move() @@ -897,6 +905,7 @@ def test_get_wdl_stats(self, stockfish: Stockfish): assert wdl_stats[1] > wdl_stats[0] * 7 assert abs(wdl_stats[0] - wdl_stats[2]) / wdl_stats[0] < 0.15 + stockfish.send_ucinewgame_command() stockfish.set_fen_position( "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" ) @@ -908,12 +917,14 @@ def test_get_wdl_stats(self, stockfish: Stockfish): stockfish.set_fen_position("8/8/8/8/8/6k1/6p1/6K1 w - - 0 1") assert stockfish.get_wdl_stats() is None + stockfish.send_ucinewgame_command() stockfish.set_fen_position( "rnbqkb1r/pp3ppp/3p1n2/1B2p3/3NP3/2N5/PPP2PPP/R1BQK2R b KQkq - 0 6" ) wdl_stats_3 = stockfish.get_wdl_stats() assert isinstance(wdl_stats_3, list) and len(wdl_stats_3) == 3 + stockfish.send_ucinewgame_command() stockfish._prepare_for_new_position() wdl_stats_4 = stockfish.get_wdl_stats(get_as_tuple=True) assert isinstance(wdl_stats_4, tuple) and len(wdl_stats_4) == 3 @@ -1128,7 +1139,8 @@ def test_invalid_fen_king_attacked(self, stockfish: Stockfish, fen): fen == "8/8/8/3k4/3K4/8/8/8 b - - 0 1" and stockfish.get_stockfish_major_version() >= 14 ): - # Since for that FEN, SF 15 actually outputs a best move without crashing (unlike SF 14 and earlier). + # Since for this FEN, more recent versions of SF (some dev versions of 14 and later) + # output a best move without crashing. return if ( fen == "2k2q2/8/8/8/8/8/8/2Q2K2 w - - 0 1" @@ -1265,3 +1277,29 @@ def test_get_engine_parameters(self, stockfish: Stockfish): params.update({"Skill Level": 10}) assert params["Skill Level"] == 10 assert stockfish._parameters["Skill Level"] == 20 + + @pytest.mark.slow + def test_uci_new_game_wait(self, stockfish: Stockfish): + stockfish.update_engine_parameters({"Hash": 2048}) + start = time.time_ns() + stockfish.send_ucinewgame_command() + assert time.time_ns() - start > 1000000 + + def test_hash_size_platform(self, stockfish: Stockfish): + max_hash = stockfish._PARAM_RESTRICTIONS["Hash"][2] + assert max_hash == 2 ** (25 if "64" in platform.machine() else 11) + + def test_threefold_detection(self, stockfish: Stockfish): + stockfish.set_depth(5) + stockfish.make_moves_from_current_position( + ["g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3"] + ) + assert compare(stockfish.get_evaluation()["value"], 0, operator.eq, int) + stockfish.make_moves_from_start( + ["g1f3", "g8f6", "f3g1", "f6g8", "g1f3", "g8f6", "f3g1", "f6g8", "g1f3"] + ) + assert compare(stockfish.get_evaluation()["value"], 0, operator.eq, int) + stockfish.make_moves_from_start(["g1f3", "g8f6", "f3g1", "f6g8", "g1f3"]) + assert compare(stockfish.get_evaluation()["value"], 0, operator.lt, int) + stockfish.make_moves_from_current_position(["g8f6", "f3g1", "f6g8", "g1f3"]) + assert compare(stockfish.get_evaluation()["value"], 0, operator.lt, int)