diff --git a/.github/workflows/black_action.yml b/.github/workflows/black_action.yml index edf42c1..8634823 100644 --- a/.github/workflows/black_action.yml +++ b/.github/workflows/black_action.yml @@ -1,5 +1,9 @@ name: black-action -on: [pull_request] +on: + pull_request: + branches: + - master + jobs: linter_name: name: runner / black diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..a5d75c4 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,29 @@ +name: PyPI Release + +on: + push: + tags: + - "*" + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v3 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine build + - name: Build the dist files + run: python -m build + - name: Publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: twine upload dist/* diff --git a/.gitignore b/.gitignore index 6c91511..49abbaf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ venv/ .idea/ .DS_Store __pycache__/ - .pstats *.egg-info/ dist/ +settings.py diff --git a/README.md b/README.md index aa38430..9874291 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -## PyAlapin, your customized chess engine +# PyAlapin, your customized chess engine Is it the best, most efficient and state of the art chess engine ? I'm pretty sure not. However, driven by passion and madness, I have developed my own chess game in Python. @@ -6,6 +6,14 @@ For your pretty eyes and your devilish smile, I share it with you. But only with Special thanks and dedication to LeMerluche, crushing its opponents on chess.com with alapin openings ❤️ +## How to install +Simply use: +```bash +pip install pyalapin +``` +You only need [numpy](https://numpy.org/) to play with with terminal interface and with Python. +You will need [kivy](https://kivy.org/) to play with the interface. + ## How to play with interface ```python from pyalapin.interface import ChessApp @@ -16,9 +24,20 @@ if __name__ == '__main__': ).run() ``` - ![](docs/scholars_mate_interface.gif) + +You can play against Stockfish by installing the official [Python interface](https://github.com/zhelyabuzhsky/stockfish). +```python +from pyalapin.player.player import Player +from pyalapin.player.stockfish_player import StockfishPlayer +from pyalapin.interface import ChessApp + +sfp = StockfishPlayer(path_to_stockfish_engine, white_side=False) +app = ChessApp(w_player=Player(True), b_player=sfp, play_with_ai=True) +app.run() +``` + ## How to play with Python commands diff --git a/envs/environment.txt b/envs/requirements.txt similarity index 100% rename from envs/environment.txt rename to envs/requirements.txt diff --git a/pyalapin/__init__.py b/pyalapin/__init__.py index 7f38ff0..706d509 100644 --- a/pyalapin/__init__.py +++ b/pyalapin/__init__.py @@ -3,5 +3,5 @@ """ # from .interface import interface as interface -__version__ = "0.0.1" +__version__ = "0.0.3" __author__ = "Vincent Auriau" diff --git a/pyalapin/engine/engine.py b/pyalapin/engine/engine.py index bd8496a..d076c40 100644 --- a/pyalapin/engine/engine.py +++ b/pyalapin/engine/engine.py @@ -598,7 +598,7 @@ def to_fen(self): fen representation and 'KQkq' """ fen = "" - for line in self.board: + for line in reversed(self.board): no_piece_count = 0 for cell in line: piece = cell.get_piece() @@ -609,13 +609,13 @@ def to_fen(self): fen += str(no_piece_count) no_piece_count = 0 letter = piece.get_str().replace(" ", "") - if piece.is_white(): - letter = letter.lower() + # if piece.is_white(): + # letter = letter.lower() fen += letter if no_piece_count > 0: fen += str(no_piece_count) fen += "/" - return fen[:-1], "KQkq" + return fen[:-1] def one_hot_encode(self, white_side=True): """Method to create a representation of the board with OneHot encode of the pieces. @@ -936,6 +936,11 @@ def draw(self, printing=True): whole_text += current_line whole_text += "\n" whole_text += boarder_line + whole_text += "\n" + whole_text += " | a | b | c | d | e | f | g | h |" + + whole_text += "\n" + whole_text += boarder_line if printing: print(whole_text + "\n") return whole_text @@ -1017,6 +1022,7 @@ def __init__( self.history = [] self.automatic_draw = automatic_draw + self.half_move_clock = 0 def reset_game(self): """Method to reset the game. Recreates the borad, the pieces and restarts the game.""" @@ -1024,6 +1030,7 @@ def reset_game(self): self.played_moves = [] self.history = [] self.to_play_player = self.player1 + self.half_move_clock = 0 def to_fen(self): """ @@ -1034,9 +1041,83 @@ def to_fen(self): str fen representation of the board. """ - pieces, castling = self.board.to_fen() + board_fen = self.board.to_fen() color_playing = "w" if self.to_play_player.is_white_side() else "b" - return pieces + " " + color_playing + " " + castling + " - 0 1" + castling = self.is_castling_possible(True) + self.is_castling_possible(False) + full_moves_nb = len(self.played_moves) // 2 + 1 + return f"{board_fen} {color_playing} {castling} {self.fen_en_passant()} {full_moves_nb} {self.half_move_clock}" + + def is_castling_possible(self, is_white_player): + """Creates FEN representation of possible castling + + Parameters + ---------- + is_white_player: bool + If castling possible checked for white player + + Returns + ------- + str: + FEN representation of possible castling + """ + + fen_possible_castling = "" + if is_white_player: + piece = self.board.get_cell(0, 4).get_piece() + if not isinstance(piece, material.King): + return "" + elif piece.has_moved or piece.castling_done: + return "" + else: + kingside_rook = self.board.get_cell(0, 7).get_piece() + if isinstance(kingside_rook, material.Rook): + if not kingside_rook.has_moved: + fen_possible_castling += "K" + queenside_rook = self.board.get_cell(0, 0).get_piece() + if isinstance(kingside_rook, material.Rook): + if not kingside_rook.has_moved: + fen_possible_castling += "Q" + else: + piece = self.board.get_cell(0, 4).get_piece() + if not isinstance(piece, material.King): + return "" + elif piece.has_moved or piece.castling_done: + return "" + else: + kingside_rook = self.board.get_cell(7, 7).get_piece() + if isinstance(kingside_rook, material.Rook): + if not kingside_rook.has_moved: + fen_possible_castling += "k" + queenside_rook = self.board.get_cell(7, 0).get_piece() + if isinstance(kingside_rook, material.Rook): + if not kingside_rook.has_moved: + fen_possible_castling += "q" + return fen_possible_castling + + def fen_en_passant(self): + """ + Creates the part of the FEN representation about En Passant. + + Returns + ------- + str + '-' or coordinate of en-passant cell if last move was double + """ + try: + last_move = self.played_moves[-1] + except: + return "-" + if not isinstance(last_move.move_piece, material.Pawn): + return "-" + + dx = last_move.start.get_x() - last_move.end.get_x() + x_cell = 8 - int(last_move.start.get_x() - last_move.end.get_x()) / 2 + y_cell = ["a", "b", "c", "d", "e", "f", "g", "h"][last_move.start.get_y()] + + if dx == 2: + return f"{y_cell}{x_cell}" + else: + return "-" def is_finished(self): """ @@ -1148,6 +1229,8 @@ def check_pat_mat(self, player): can_player_move = self.can_player_move(player) if can_player_move: + if self.half_move_clock >= 50: + return 1 return 0 # If player cannot move any piece else: @@ -1205,7 +1288,11 @@ def move(self, move, player): else: assert moved_piece.is_white() == player.is_white_side() # Actually move pieces - move.move_pieces() + reset_half_move_clock = move.move_pieces() + if reset_half_move_clock: + self.half_move_clock = 0 + else: + self.half_move_clock += 1 # Store move self.played_moves.append(move) @@ -1248,6 +1335,7 @@ def update_status(self): """ game_status = self.check_pat_mat(self.player1) + self.game_status.append(game_status) # Pat if game_status == 1: return False, "black&white" diff --git a/pyalapin/engine/material.py b/pyalapin/engine/material.py index a76941c..58b842a 100644 --- a/pyalapin/engine/material.py +++ b/pyalapin/engine/material.py @@ -495,7 +495,8 @@ def get_str(self): str String representation of the piece """ - return " P " + repr = " P " + return repr if self.is_white() else repr.lower() class Bishop(Piece): @@ -661,7 +662,8 @@ def get_str(self): str String representation of the piece """ - return " B " + repr = " B " + return repr if self.is_white() else repr.lower() class Rook(Piece): @@ -830,7 +832,8 @@ def get_str(self): str String representation of the piece """ - return " R " + repr = " R " + return repr if self.is_white() else repr.lower() class Knight(Piece): @@ -927,7 +930,8 @@ def get_str(self): str String representation of the piece """ - return " N " + repr = " N " + return repr if self.is_white() else repr.lower() def get_potential_moves(self, x, y): """Method to list all the possible moves from coordinates. Only uses authorized movements, no other pieces on a @@ -1178,7 +1182,8 @@ def get_str(self): str String representation of the piece """ - return " Q " + repr = " Q " + return repr if self.is_white() else repr.lower() class King(Piece): @@ -1410,7 +1415,8 @@ def get_str(self): str String representation of the piece """ - return " K " + repr = " K " + return repr if self.is_white() else repr.lower() def is_checked(self, board): """Method to verify that the king at its current position is not threatened / checked by opponent material. diff --git a/pyalapin/engine/move.py b/pyalapin/engine/move.py index 196636a..f9499cb 100644 --- a/pyalapin/engine/move.py +++ b/pyalapin/engine/move.py @@ -168,11 +168,9 @@ def _is_castling(self): Whether self is castling or not. """ if not isinstance(self.moved_piece, material.King): - ###print("not castling becasuse king not moved") return False + elif self.moved_piece.castling_done or self.moved_piece.has_moved: - ###print(self.moved_piece.castling_done) - ###print(self.moved_piece.has_moved) return False else: @@ -215,7 +213,6 @@ def _is_castling(self): self.board.get_cell(self.start.x, 4), ] else: - ###print('king did not move to a castling position') return False # Verifying that the cells between rook and king are empty and that starting @@ -253,10 +250,6 @@ def _is_castling(self): return True else: - ###print('Conditions for castling:') - ###print('Rook has not moved:', rook_to_move.has_moved) - ###print('Cells in between empty:', empty_cells_check) - ###print('Cells in between not threatened:', not_threatened_cells) return False def _is_en_passant(self): @@ -348,9 +341,15 @@ def move_pieces(self): """ # Do everything from coordinates so that only board needs to be copied in self.deepcopy() ? + if isinstance(self.moved_piece, material.Pawn): + reset_halfmove_clock = True + else: + reset_halfmove_clock = False + # Kills Piece on landing Cell if needed. if self.killed_piece is not None: self.board.kill_piece_from_coordinates((self.end.x, self.end.y)) + reset_halfmove_clock = True # Moves Piece on the Board self.board.move_piece_from_coordinates( (self.start.x, self.start.y), (self.end.x, self.end.y) @@ -372,6 +371,7 @@ def move_pieces(self): # Sets the different movement related attributes of Pieces self._set_moved_attribute() + return reset_halfmove_clock def is_possible_move(self, check_chess=True): # REFONDRE, particulièrement, faire en sorte qu'on ne vérifie chaque condition qu'une seule fois diff --git a/pyalapin/interface/interface.py b/pyalapin/interface/interface.py index a0dac99..fa4997f 100644 --- a/pyalapin/interface/interface.py +++ b/pyalapin/interface/interface.py @@ -294,13 +294,14 @@ def click_cell(self, event): # Resets background colors of player ai_move = self.game.player2.time_to_play(self.game.board) + print(ai_move) self.game.board.draw() game_is_on = self.game.move(ai_move, self.game.player2) # Verify game is still going on # Actually we consider that an AI cannot trigger an impossible move here # Maybe should be modified ? - + print("game_is_on", game_is_on) if game_is_on[0]: self.update() ( @@ -380,17 +381,24 @@ class ChessApp(App): Main app to use to play game, by calling ChessApp().build() and then players. """ - def __init__(self, play_with_ai=False, **kwargs): + def __init__(self, play_with_ai=False, w_player=None, b_player=None, **kwargs): """ Initialization, with precision whether or not playing with AI. """ super().__init__(**kwargs) self.play_with_ai = play_with_ai + self.w_player = w_player + self.b_player = b_player def build(self): """ Builds the game and the display board along with it. """ - game = ChessGame(automatic_draw=False, ai=self.play_with_ai) + game = ChessGame( + automatic_draw=False, + ai=self.play_with_ai, + player1=self.w_player, + player2=self.b_player, + ) print("game created") return BoardInterface(game) diff --git a/pyalapin/player/__init__.py b/pyalapin/player/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyalapin/player/player.py b/pyalapin/player/player.py index d775a02..4888a27 100644 --- a/pyalapin/player/player.py +++ b/pyalapin/player/player.py @@ -1,7 +1,5 @@ import numpy as np -from pyalapin.engine.move import Move - class Player: """Base class players. If human player only here to ensure that right player is playing right Pieces. diff --git a/pyalapin/player/stockfish_player.py b/pyalapin/player/stockfish_player.py new file mode 100644 index 0000000..739a8eb --- /dev/null +++ b/pyalapin/player/stockfish_player.py @@ -0,0 +1,87 @@ +from stockfish import Stockfish + +from pyalapin.player.player import Player +from pyalapin.engine.move import Move + + +class StockfishPlayer(Player): + """ + A first AI that plays totally randomly. Selects one move among all possibles and plays it. + """ + + def __init__( + self, path_to_dirsave, elo=1000, depth=18, threads=1, hash_pow=4, **kwargs + ): + super().__init__(**kwargs) + self.elo = elo + self.path_to_dirsave = path_to_dirsave + self.depth = depth + self.threads = threads + self.hash = 2**hash_pow + + params = { + "Threads": self.threads, + "Hash": self.hash, + "UCI_Elo": self.elo, + } + self.stockfish = Stockfish( + path=self.path_to_dirsave, depth=self.depth, parameters=params + ) + + self.letter_to_coordinate = { + "a": 0, + "b": 1, + "c": 2, + "d": 3, + "e": 4, + "f": 5, + "g": 6, + "h": 7, + } + + def __str__(self): + """Creates a string representation of the player. + + Returns + ------- + str + String representation of the player + """ + + return "Stockfish of elo {self.elo}" + + def quit(self): + self.stockfish.send_quit_command() + + def _sf_to_own_coordinates(self, coordinates): + return (int(coordinates[1]) - 1, self.letter_to_coordinate[coordinates[0]]) + + def get_move_from_fen(self, fen): + self.stockfish.set_fen_position(fen) + stockfish_move = self.stockfish.get_best_move() + print(f"StockFish best move: {stockfish_move}") + start_cell_coordinates = self._sf_to_own_coordinates(stockfish_move[:2]) + end_cell_coordinates = self._sf_to_own_coordinates(stockfish_move[2:]) + + print("Transformed Coordinates", start_cell_coordinates, end_cell_coordinates) + return start_cell_coordinates, end_cell_coordinates + + def time_to_play(self, board): + """Potential method that returns a move. + + + Parameters + ---------- + board : engine.Board on which to play. + + Returns + ------- + move.Move move to operate on board. + """ + + fen_repr = board.to_fen() + print(fen_repr) + start, end = self.get_move_from_fen(fen_repr) + move = Move(self, board, board.get_cell(*start), board.get_cell(*end)) + print(move, board.get_cell(*start), board.get_cell(*end)) + return move diff --git a/pyproject.toml b/pyproject.toml index 36232b3..ad22898 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,19 @@ build-backend = "setuptools.build_meta" [project] name = "pyalapin" -version = "0.0.1" +version = "0.0.3" authors = [ { name = "Vincent Auriau", email = "vincent.auriau.dev@gmail.com"}, ] license = { file = "LICENSE" } readme = "README.md" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Topic :: Games/Entertainment :: Board Games", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] description = "Custom chess engine built specifically to develop AIs" keywords = ["chess", "ai", "interface"] @@ -18,13 +25,11 @@ dependencies = [ ] requires-python = ">=3.8" [project.optional-dependencies] -dev = ["black", "bumpver", "isort", "pip-tools", "pytest", "kivy"] +dev = ["black", "bumpver", "isort", "pip-tools", "pytest", "kivy", "stockfish"] [project.urls] Homepage = "https://github.com/VincentAuriau/custom-chess-engine" -[tool.setuptools] -packages = [ - "tests", - "pyalapin" -] +[tool.setuptools.packages.find] +include=["pyalapin*"] +namespaces=false diff --git a/tests/stockfish_localtest.py b/tests/stockfish_localtest.py new file mode 100644 index 0000000..7dccef4 --- /dev/null +++ b/tests/stockfish_localtest.py @@ -0,0 +1,24 @@ +if __name__ == "__main__": + import sys + + sys.path.append("../pyalapin") + import os + import stockfish + + from pyalapin.player.stockfish_player import StockfishPlayer + from pyalapin.player.player import Player + from pyalapin.engine import ChessGame + + from settings import settings + + sfp = StockfishPlayer(settings["stockfish_path"], white_side=True) + game = ChessGame() + co1, co2 = sfp.get_move_from_fen(game.to_fen()) + game.board.draw() + + # sfp.quit() + from pyalapin.interface import ChessApp + + sfp = StockfishPlayer(settings["stockfish_path"], white_side=False) + app = ChessApp(w_player=Player(True), b_player=sfp, play_with_ai=True) + app.run() diff --git a/tests/stockfish_tes.py b/tests/stockfish_tes.py deleted file mode 100644 index 2d9b54d..0000000 --- a/tests/stockfish_tes.py +++ /dev/null @@ -1,3 +0,0 @@ -from stockfish import Stockfish - -stockfish = Stockfish(path="../") diff --git a/tests/unit_test/engine_test.py b/tests/unit_test/engine_test.py index c859346..a7c8376 100644 --- a/tests/unit_test/engine_test.py +++ b/tests/unit_test/engine_test.py @@ -32,8 +32,8 @@ def test_promotion_to_rook(): game.player1, 6, 6, 7, 6, extras={"promote_into": "rook"} ) assert ( - game.board.to_fen()[0] == "rnbqkbnr/pppp1ppp/8/8/P7/7N/1PPPP2P/RNBQKBrR" - ), game.board.to_fen()[0] + game.board.to_fen() == "rnbqkbRr/1pppp2p/7n/p7/8/8/PPPP1PPP/RNBQKBNR" + ), game.board.to_fen() def test_default_promotion(): @@ -51,8 +51,8 @@ def test_default_promotion(): game.move_from_coordinates(game.player2, 5, 0, 4, 0) game.move_from_coordinates(game.player1, 6, 6, 7, 6) assert ( - game.board.to_fen()[0] == "rnbqkbnr/pppp1ppp/8/8/P7/7N/1PPPP2P/RNBQKBqR" - ), game.board.to_fen()[0] + game.board.to_fen() == "rnbqkbQr/1pppp2p/7n/p7/8/8/PPPP1PPP/RNBQKBNR" + ), game.board.to_fen() def test_working_castling(): @@ -76,9 +76,9 @@ def test_working_castling(): # big castling move game.move_from_coordinates(game.player2, 7, 4, 7, 2) assert ( - game.board.to_fen()[0] - == "r1b2rk1/ppppqppp/2n2n2/2b1p3/4P1Q1/2NP4/PPPB1PPP/2KR1BNR" - ), game.board.to_fen()[0] + game.board.to_fen() + == "2kr1bnr/pppb1ppp/2np4/4p1q1/2B1P3/2N2N2/PPPPQPPP/R1B2RK1" + ), game.board.to_fen() def test_failing_castling(): @@ -101,9 +101,8 @@ def test_failing_castling(): _, status = game.move_from_coordinates(game.player1, 0, 4, 0, 6) assert status == 0 assert ( - game.board.to_fen()[0] - == "rnbqk2r/pppp1ppp/5n2/2b1p3/4P1Q1/2N5/PPPP1PPP/R1B1KBNR" - ), game.board.to_fen()[0] + game.board.to_fen() == "r1b1kbnr/pppp1ppp/2n5/4p1q1/2B1P3/5N2/PPPP1PPP/RNBQK2R" + ), game.board.to_fen() def test_en_passant(): @@ -115,8 +114,8 @@ def test_en_passant(): game.move_from_coordinates(game.player2, 6, 5, 4, 5) game.move_from_coordinates(game.player1, 4, 4, 5, 5) assert ( - game.board.to_fen()[0] == "rnbqkbnr/pppp1ppp/8/8/5P2/P4p2/1PPPP1PP/RNBQKBNR" - ), game.board.to_fen()[0] + game.board.to_fen() == "rnbqkbnr/1pppp1pp/p4P2/5p2/8/8/PPPP1PPP/RNBQKBNR" + ), game.board.to_fen() def test_blocked_by_mat():