├── .coveragerc ├── .github └── workflows │ ├── build.yml │ ├── code-cov.yml │ ├── codeql-analysis.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── codecov.yml ├── draughts ├── PDN.py ├── __init__.py ├── ballot_files │ ├── 11_english.json │ ├── 11_italian.json │ ├── 150russian_and_brazilian.json │ ├── 2move_english.json │ ├── 3move_english.json │ ├── 4move_english.json │ ├── 5move_english.json │ ├── __init__.py │ ├── brazilian.json │ └── russian.json ├── ballots.py ├── convert.py ├── core │ ├── __init__.py │ ├── board.py │ ├── board_initializer.py │ ├── board_searcher.py │ ├── game.py │ ├── move.py │ ├── piece.py │ └── variant.py ├── engine.py ├── engines │ ├── __init__.py │ ├── checkerboard.py │ ├── checkerboard_extra │ │ ├── __init__.py │ │ ├── engine_64.py │ │ ├── engine_client.py │ │ ├── engine_server.py │ │ └── get_checker_board.py │ ├── dxp.py │ ├── dxp_communication │ │ ├── __init__.py │ │ ├── dxp_classes.py │ │ └── dxp_communication.py │ └── hub.py ├── svg.py └── tournament.py ├── examples ├── board.svg └── engine_pondering.py ├── other_licenses ├── ImparaAI checkers LICENSE ├── akalverboer DXC100_draughts_client LICENSE └── fishnet LICENSE.txt ├── pyproject.toml ├── setup.cfg └── test_pydraughts ├── __init__.py ├── conftest.py ├── test_ambiguous_moves.py ├── test_ballots.py ├── test_board.py ├── test_convert.py ├── test_dxp_communication.py ├── test_engines.py ├── test_game.py ├── test_move.py ├── test_pdn.py ├── test_piece.py ├── test_repr.py ├── test_svg.py ├── test_tournament.py └── test_variants.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # It is run through msl-loadlib, so coverage doesn't detect it. 4 | draughts/engines/checkerboard_extra/engine_server.py 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build the library. 2 | 3 | name: Build 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | branches: [ main ] 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | python: ["3.13"] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python }} 26 | - name: Install dependencies 27 | if: ${{ matrix.os == 'windows-latest' }} 28 | run: | 29 | py -m pip install --upgrade pip 30 | py -m pip install --upgrade build 31 | - name: Install dependencies 32 | if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest' }} 33 | run: | 34 | python3 -m pip install --upgrade pip 35 | python3 -m pip install --upgrade build 36 | - name: Build 37 | if: ${{ matrix.os == 'windows-latest' }} 38 | run: | 39 | py -m build 40 | - name: Build 41 | if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest' }} 42 | run: | 43 | python3 -m build 44 | -------------------------------------------------------------------------------- /.github/workflows/code-cov.yml: -------------------------------------------------------------------------------- 1 | name: CodeCov 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | run: 7 | runs-on: windows-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | with: 11 | fetch-depth: ‘2’ 12 | 13 | - name: Setup Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: "3.13" 17 | - name: Generate Report 18 | run: | 19 | pip install msl-loadlib pytest pytest-timeout pytest-cov requests 20 | pytest --cov=draughts --cov-report=xml 21 | coverage report --show-missing 22 | - name: Upload Coverage to Codecov 23 | uses: codecov/codecov-action@v2 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '35 11 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v3 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v2 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v2 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v2 72 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macos-latest, windows-latest] 19 | python: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python }} 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: ${{ matrix.python }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install flake8 msl-loadlib pytest pytest-timeout requests 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 37 | - name: Test with pytest 38 | run: | 39 | pytest --log-cli-level=10 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .idea/ 3 | __pycache__ 4 | draughts/__pycache__ 5 | draughts/core/__pycache__ 6 | draughts/engines/__pycache__ 7 | draughts/engines/dxp_communication/__pycache__ 8 | draughts/engines/checkerboard_extra/__pycache__ 9 | draughts/engines/checkerboard_extra/engine_name.txt 10 | .gitattributes 11 | 12 | *.swp 13 | *.py[cod] 14 | *~ 15 | .coverage 16 | .coveralls.yml 17 | nosetests.xml 18 | .tox 19 | 20 | dist 21 | build 22 | pydraughts.egg-info 23 | draughts/pydraughts.egg-info 24 | docs/_build 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2024 Ioannis Pantidis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include draughts/ballot_files *.json 2 | recursive-include examples * -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pydraughts 2 | [![PyPI version](https://badge.fury.io/py/pydraughts.svg)](https://badge.fury.io/py/pydraughts) [![Tests](https://github.com/AttackingOrDefending/pydraughts/actions/workflows/tests.yml/badge.svg)](https://github.com/AttackingOrDefending/pydraughts/actions/workflows/tests.yml) [![Build](https://github.com/AttackingOrDefending/pydraughts/actions/workflows/build.yml/badge.svg)](https://github.com/AttackingOrDefending/pydraughts/actions/workflows/build.yml) [![CodeQL](https://github.com/AttackingOrDefending/pydraughts/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/AttackingOrDefending/pydraughts/actions/workflows/codeql-analysis.yml) [![codecov](https://codecov.io/gh/AttackingOrDefending/pydraughts/branch/main/graph/badge.svg?token=ZSPXIVSAWN)](https://codecov.io/gh/AttackingOrDefending/pydraughts) 3 | 4 | pydraughts is a draughts (checkers) library for Python with move generation, SVG visualizations, PDN reading and writing, engine communication and balloted openings. It is based on [ImparaAI/checkers](https://github.com/ImparaAI/checkers). 5 | 6 | Installing 7 | ---------- 8 | 9 | Download and install the latest release: 10 | 11 | pip install pydraughts 12 | 13 | ## Features 14 | 15 | **Variants:** 16 | * Standard (International) 17 | * Frisian 18 | * frysk! 19 | * Antidraughts 20 | * Breakthrough 21 | * Russian 22 | * Brazilian 23 | * English/American 24 | * Italian 25 | * Turkish 26 | 27 | **Engine protocols:** 28 | * Hub 29 | * DXP 30 | * CheckerBoard 31 | 32 | **PDN Reading and Writing** 33 |

34 | * Import pydraughts 35 | ```python 36 | from draughts import Board, Move, WHITE, BLACK 37 | ``` 38 | * Create a game 39 | ```python 40 | board = Board(variant="standard", fen="startpos") 41 | ``` 42 | * Make a move 43 | ```python 44 | move = Move(board, steps_move=[34, 30]) 45 | board.push(move) 46 | 47 | # Multi-capture 48 | board2 = Board(fen="W:WK40:B19,29") 49 | board2.push(Move(board2, pdn_move='40x14')) 50 | ``` 51 | * Get a visual representation of the board as SVG 52 | ```python 53 | from draughts import svg 54 | svg.create_svg(Board(fen="B:W16,19,33,34,47,K4:B17,25,26")) 55 | ``` 56 | ![SVG Board](examples/board.svg) 57 | * Get a visual representation of the board in the terminal 58 | ```python 59 | print(board) 60 | 61 | """ 62 | | b | | b | | b | | b | | b 63 | --------------------------------------- 64 | b | | b | | b | | b | | b | 65 | --------------------------------------- 66 | | b | | b | | b | | b | | b 67 | --------------------------------------- 68 | b | | b | | b | | b | | b | 69 | --------------------------------------- 70 | | | | | | | | | | 71 | --------------------------------------- 72 | | | | | | | | | w | 73 | --------------------------------------- 74 | | w | | w | | w | | | | w 75 | --------------------------------------- 76 | w | | w | | w | | w | | w | 77 | --------------------------------------- 78 | | w | | w | | w | | w | | w 79 | --------------------------------------- 80 | w | | w | | w | | w | | w | 81 | """ 82 | ``` 83 | * Get legal moves 84 | ```python 85 | moves = board.legal_moves() 86 | ``` 87 | * Detect wins and draws 88 | ```python 89 | has_white_won = board.winner() == WHITE 90 | is_draw = board.winner() == 0 91 | winnner = board.winner() 92 | is_game_over = board.is_over() 93 | ``` 94 | * Convert move to other types 95 | ```python 96 | move = Move(board, board_move=moves[0].board_move).pdn_move 97 | ``` 98 | * Get fen 99 | ```python 100 | fen = game.fen 101 | ``` 102 | * Communicate with engines 103 | ```python 104 | from draughts.engine import HubEngine, Limit 105 | engine = HubEngine(["scan.exe", "hub"]) 106 | engine.init() 107 | limit = Limit(time=10) 108 | engine_move = engine.play(board, limit, ponder=False) 109 | ``` 110 | * Read PDN games 111 | ```python 112 | from draughts.PDN import PDNReader 113 | games = PDNReader(filename=filepath) 114 | game = games.games[0] 115 | moves = game.moves 116 | ``` 117 | * Write PDN games 118 | ```python 119 | from draughts.PDN import PDNWriter 120 | games = PDNWriter(filename=filepath, board=board) 121 | ``` 122 | * Get a ballot 123 | ```python 124 | from draughts.ballots import Ballots 125 | ballots = Ballots('english') 126 | ballot1 = ballots.get_ballot() 127 | ballot2 = ballots.get_ballot() 128 | ``` 129 | * Run tournaments 130 | ```python 131 | from draughts.tournament import RoundRobin 132 | player1 = (["scan.exe", "hub"], "hub", {}, None) 133 | player2 = ("kr_hub.exe", "hub", {}, None) 134 | players = [player1, player2] 135 | tournament = RoundRobin("tournament.pdn", players, start_time=20, increment=0.2, games_per_pair=4) 136 | scores = tournament.play() 137 | print(scores) 138 | tournament.print_standings() 139 | ``` 140 | 141 | ## Example Engines 142 | Some engines that can be used with `pydraughts`. 143 | 144 | | Engine | Protocol | 145 | |:-------------------------------------------------------------------------------------------|:-------------| 146 | | [Kingsrow (international)](https://edgilbert.org/InternationalDraughts/download_links.htm) | Hub & DXP | 147 | | [Scan](https://hjetten.home.xs4all.nl/scan/scan.html) | Hub & DXP | 148 | | [Moby Dam](https://hjetten.home.xs4all.nl/mobydam/mobydam.html) | DXP | 149 | | [Kingsrow (english)](https://edgilbert.org/EnglishCheckers/KingsRowEnglish.htm) | CheckerBoard | 150 | | [Kingsrow (italian)](https://edgilbert.org/ItalianCheckers/KingsRowItalian.htm) | CheckerBoard | 151 | | [Cake](https://www.fierz.ch/download.php) | CheckerBoard | 152 | | [Kallisto](https://www.igorkorshunov.narod.ru/Draughts/Kallisto4.rar) | CheckerBoard | 153 | 154 | ## Selected Projects 155 | If you like, share your interesting project that uses pydraughts. 156 | 157 | | Projects | 158 | |-----------------------------------------------------------------------| 159 | | Checkers Reinforcement Learning — https://github.com/d3da/checkers-rl | 160 | 161 | ## Acknowledgements 162 | Thanks to [fishnet](https://github.com/lichess-org/fishnet/tree/ebd2a5e16d37135509cbfbff9998e0b798866ef5) which was modified to add support for Hub engines. Thanks to [akalverboer](https://github.com/akalverboer) for their [DXC100_draughts_client](https://github.com/akalverboer/DXC100_draughts_client) which was modified to add support for DXP engines. 163 | 164 | ## License 165 | pydraughts is licensed under The MIT License. Check out `LICENSE` for the full text. 166 | The licenses of the other projects that pydraughts uses are in the `other_licenses` folder. 167 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 10% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 10% 11 | -------------------------------------------------------------------------------- /draughts/PDN.py: -------------------------------------------------------------------------------- 1 | import re 2 | import string 3 | from draughts.convert import fen_to_variant 4 | from draughts import Board, Move 5 | from typing import List, Optional, Dict, Union 6 | 7 | 8 | class _PDNGame: 9 | """Read one PDN game.""" 10 | def __init__(self, pdn_text: str) -> None: 11 | self.values_to_variant = {20: "standard", 21: "english", 22: "italian", 23: "american pool", 24: "spanish", 12 | 25: "russian", 26: "brazilian", 27: "canadian", 28: "portuguese", 29: "czech", 13 | 30: "turkish", 31: "thai", 40: "frisian", 41: "spantsiretti"} 14 | self.tags: Dict[str, str] = {} 15 | self.moves: List[str] = [] 16 | self.variant: Optional[str] = None 17 | self.notation: Optional[int] = None 18 | self.notation_type: Optional[str] = None 19 | self.game_ending = '*' 20 | self.pdn_text = pdn_text 21 | self._rest_of_games: List[str] = [] 22 | self._read() 23 | 24 | def _read(self) -> None: 25 | """Read a PDN game.""" 26 | lines = self.pdn_text.split('\n') 27 | tag_lines = [] 28 | last_tag_line = -1 29 | for index, line in enumerate(lines): 30 | if line.startswith('['): 31 | tag_lines.append(line) 32 | last_tag_line = index 33 | elif re.sub(r'\s', '', line): 34 | break 35 | 36 | for tag_line in tag_lines: 37 | line = tag_line[1:-1] 38 | quote_index = line.index('"') 39 | name = line[:quote_index - 1] 40 | value = line[quote_index + 1:-1] 41 | self.tags[name] = value 42 | 43 | rest_of_games = [] 44 | last_move_line = -1 45 | move_lines = [] 46 | for index, line in enumerate(lines[last_tag_line + 1:]): 47 | split_line = re.split(r'[\s|\]](1-0|1/2-1/2|0-1|2-0|1-1|0-2|0-0|\*)[\s|\[]', ' ' + line + ' ', maxsplit=1) 48 | if len(split_line) == 3: 49 | move_lines.append(split_line[0]) 50 | last_move_line = index 51 | self.game_ending = split_line[1] 52 | rest_of_games.append(split_line[2]) 53 | break 54 | if re.sub(r'\s', '', line): 55 | move_lines.append(line) 56 | last_move_line = index 57 | 58 | rest_of_games += lines[last_tag_line + 1 + last_move_line + 1:] 59 | 60 | str_moves = " ".join(move_lines) 61 | 62 | # Changes to the PDN. 63 | 64 | # From https://stackoverflow.com/a/37538815/10014873 65 | def remove_text_between_parens(text: str) -> str: 66 | n = 1 # Run at least once. 67 | while n: 68 | text, n = re.subn(r'\([^()]*\)', '', text) # Remove non-nested/flat balanced parts. 69 | return text 70 | 71 | def remove_text_between_brackets(text: str) -> str: 72 | n = 1 # Run at least once. 73 | while n: 74 | text, n = re.subn(r'{[^{}]*}', '', text) # Remove non-nested/flat balanced parts. 75 | return text 76 | 77 | str_moves = remove_text_between_parens(str_moves) 78 | str_moves = remove_text_between_brackets(str_moves) 79 | str_moves = re.sub(r" +", " ", str_moves) 80 | str_moves = re.sub(r"\$[0-9]+", "", str_moves) 81 | str_moves = str_moves.replace('. ...', '...') 82 | str_moves = str_moves.replace('...', '.') 83 | str_moves = str_moves.replace('?', '') 84 | str_moves = str_moves.replace('!', '') 85 | str_moves = str_moves.replace('. ', '.') 86 | str_moves = str_moves.replace('- ', '-') 87 | str_moves = str_moves.replace('x ', 'x') 88 | str_moves = str_moves.replace(': ', ':') 89 | 90 | move_numbers = re.findall(r"\d+\.", str_moves) 91 | double_numbers = list(set(filter(lambda move: move_numbers.count(move) >= 2, move_numbers))) 92 | for move_number in double_numbers: 93 | str_moves = (str_moves[:str_moves.index(move_number) + len(move_number)] + 94 | str_moves[str_moves.index(move_number) + len(move_number):].replace(move_number, "")) 95 | 96 | moves = str_moves.split(".")[1:] 97 | if not moves: 98 | return 99 | starts = self.tags.get('FEN', 'W') 100 | if starts.startswith('W'): 101 | clean_moves = [] 102 | list_moves = [move.split()[:2] for move in moves] 103 | for move in list_moves: 104 | clean_moves.extend(move) 105 | else: 106 | clean_moves = [moves[0].split()[0]] 107 | list_moves = [[moves[0].split()[0]]] + [move.split()[:2] for move in moves[1:]] 108 | for move in list_moves: 109 | clean_moves.extend(move) 110 | results = ["1-0", "1/2-1/2", "0-1", "2-0", "1-1", "0-2", "0-0", "*"] 111 | clean_moves = clean_moves[:-1] if clean_moves[-1] in results else clean_moves 112 | self.moves = clean_moves 113 | 114 | if "GameType" in self.tags: 115 | game_type = self.tags["GameType"] 116 | values = game_type.split(',') 117 | variant_number = int(values[0]) 118 | self.variant = self.values_to_variant.get(variant_number, None) 119 | if len(values) == 6: 120 | notation = values[4] 121 | self.notation_type = notation[0].lower() 122 | self.notation = int(notation[1]) 123 | else: # Try to guess the variant. 124 | board_10 = ['31', '32', '33', '34', '35', '16', '17', '18', '19', '20'] 125 | board_8 = ['21', '22', '23', '24', '9', '09', '10', '11', '12'] 126 | first_move = moves[0] 127 | if list(filter(lambda move: first_move.startswith(move), board_10)): 128 | self.variant = "standard" 129 | elif list(filter(lambda move: first_move.startswith(move), board_8)): 130 | self.variant = "english" 131 | elif first_move[0] in string.ascii_letters: 132 | self.variant = "russian" 133 | 134 | self._rest_of_games = rest_of_games 135 | 136 | def get_titles(self) -> List[str]: 137 | """Get player titles.""" 138 | return [self.tags.get("WhiteTitle", ""), self.tags.get("BlackTitle", "")] 139 | 140 | def get_ratings(self) -> List[str]: 141 | """Get player ratings.""" 142 | return [self.tags.get("WhiteRating", ""), self.tags.get("BlackRating", "")] 143 | 144 | def get_na(self) -> List[str]: 145 | """Get player network address.""" 146 | return [self.tags.get("WhiteNA", ""), self.tags.get("BlackNA", "")] 147 | 148 | def get_types(self) -> List[str]: 149 | """Get player types (human, computer, etc.).""" 150 | return [self.tags.get("WhiteType", ""), self.tags.get("BlackType", "")] 151 | 152 | def _get_rest_of_games(self) -> str: 153 | """Get the rest of the games.""" 154 | # This class only reads the first game. You can get the rest with this function. 155 | return '\n'.join(self._rest_of_games) 156 | 157 | 158 | class PDNReader: 159 | """Read PDN games.""" 160 | def __init__(self, pdn_text: Optional[str] = None, filename: Optional[str] = None, 161 | encodings: Union[List[str], str, None] = None) -> None: 162 | if encodings is None: 163 | encodings = ['utf8', 'ISO 8859/1'] 164 | if type(encodings) == str: 165 | encodings = [encodings] 166 | if filename: 167 | pdn_text = '' 168 | for encoding in encodings: 169 | try: 170 | with open(filename, encoding=encoding) as pdn_file: 171 | pdn_text = pdn_file.read() 172 | break 173 | except Exception: 174 | pass 175 | assert pdn_text is not None 176 | self.pdn_text = pdn_text 177 | self.pdn_text = re.sub('\n +', '\n', self.pdn_text) 178 | self.pdn_text = re.sub('\n\n+', '\n\n', self.pdn_text) 179 | self.games = [] 180 | game = _PDNGame(self.pdn_text) 181 | self.games.append(game) 182 | more_games = game._get_rest_of_games() 183 | while re.sub(r'\s', '', more_games): 184 | game = _PDNGame(more_games) 185 | self.games.append(game) 186 | more_games = game._get_rest_of_games() 187 | 188 | 189 | class PDNWriter: 190 | """Write a game to a file.""" 191 | VARIANT_TO_GAMETYPE = {'standard': 20, 'english': 21, 'italian': 22, 'russian': 25, 'brazilian': 26, 'turkish': 30, 192 | 'frisian': 40, 'frysk!': 40} 193 | SHORT_TO_LONG_GAMETYPE = {'20': '20,W,10,10,N2,0', '21': '21,B,8,8,N1,0', '22': '22,W,8,8,N2,1', 194 | '25': '25,W,8,8,A0,0', '26': '26,W,8,8,A0,0', '30': '30,W,8,8,A0,0', '40': '40,W,10,10,N2,0'} 195 | 196 | def __init__(self, filename: str, board: Optional[Board] = None, moves: Union[List[str], List[Move], None] = None, 197 | variant: Optional[str] = None, starting_fen: Optional[str] = None, 198 | tags: Optional[Dict[str, Union[str, int]]] = None, game_ending: str = '*', 199 | replay_moves_from_board: bool = True, file_encoding: str = 'utf8', file_mode: str = 'a') -> None: 200 | """ 201 | :param replay_moves_from_board: The already saved pdn_move in move_stack may be wrong because it is pseudolegal 202 | and doesn't account for ambiguous moves. If replay_moves_from_board is enabled, it will replay all the moves to 203 | find the correct representation of them. 204 | """ 205 | assert board or moves is not None 206 | self.pdn_text = '' 207 | self.notation_type: Optional[str] = None 208 | self.notation: Optional[int] = None 209 | 210 | self.board = board 211 | self.moves: Union[List[str], List[Move]] 212 | if self.board: 213 | self.moves = self.board.move_stack 214 | self.variant = self.board.variant 215 | self.starting_fen = self.board.initial_fen 216 | self.tags = tags or {} 217 | else: 218 | self.moves = moves 219 | self.variant = variant or 'standard' 220 | self.starting_fen = starting_fen or self._startpos_to_fen() 221 | self.tags = tags or {} 222 | self.game_ending = game_ending 223 | 224 | self.replay_moves_from_board = replay_moves_from_board 225 | self.filename = filename 226 | self.file_encoding = file_encoding 227 | self.file_mode = file_mode 228 | self._fix_ambiguous_moves() 229 | self._write() 230 | 231 | def _fix_ambiguous_moves(self) -> None: 232 | """Replay the moves to fix any ambiguous PDN move.""" 233 | if self.moves and type(self.moves[0]) == str: 234 | return 235 | game = Board(self.variant, self.starting_fen) 236 | correct_moves = [] 237 | for move in self.moves: 238 | correct_move = Move(game, board_move=move.board_move) 239 | correct_moves.append(correct_move) 240 | game.push(correct_move) 241 | self.moves = correct_moves 242 | 243 | def _write(self) -> None: 244 | """Write the PDN file.""" 245 | pdn_text = '' 246 | if 'GameType' not in self.tags: 247 | short_gametype = str(self.VARIANT_TO_GAMETYPE.get(self.variant.lower(), 20)) 248 | long_gametype = self.SHORT_TO_LONG_GAMETYPE[short_gametype] 249 | self.tags['GameType'] = long_gametype 250 | if 'FEN' not in self.tags: 251 | self.tags['FEN'] = self.starting_fen 252 | for tag in self.tags: 253 | pdn_text += f'[{tag} "{self.tags[tag]}"]\n' 254 | pdn_text += '\n' 255 | 256 | standard_moves = [] 257 | for move in self.moves: 258 | if type(move) != str: 259 | standard_move = move.pdn_move 260 | else: 261 | standard_move = move 262 | 263 | standard_moves.append(standard_move) 264 | 265 | if len(standard_moves) == 1 and self.starting_fen[0] == 'W': 266 | pdn_text += f'1. {standard_moves[0]}' 267 | standard_moves = [] 268 | elif standard_moves: 269 | if self.starting_fen[0] == 'W': 270 | pdn_text += f'1. {standard_moves[0]} {standard_moves[1]}' 271 | standard_moves = standard_moves[2:] 272 | else: 273 | pdn_text += f'1... {standard_moves[0]}' 274 | standard_moves = standard_moves[1:] 275 | 276 | write_move_number = True 277 | move_number = 2 278 | while standard_moves: 279 | move_number_to_write = f'{move_number}. ' if write_move_number else '' 280 | pdn_text += f' {move_number_to_write}{standard_moves.pop(0)}' 281 | write_move_number = not write_move_number 282 | if write_move_number: 283 | move_number += 1 284 | pdn_text += f' {self.game_ending}' 285 | pdn_text += '\n\n' 286 | 287 | self.pdn_text = pdn_text 288 | 289 | if self.filename: 290 | with open(self.filename, self.file_mode, encoding=self.file_encoding) as file: 291 | file.write(self.pdn_text) 292 | 293 | def _startpos_to_fen(self) -> str: 294 | """Get the starting fen.""" 295 | if self.variant == 'frysk!': 296 | fen = 'W:W46,47,48,49,50:B1,2,3,4,5' 297 | elif self.variant == 'turkish': 298 | fen = 'W:W41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56:B9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24' 299 | elif self.variant in ['brazilian', 'russian', 'english', 'italian']: 300 | fen = 'W:W21,22,23,24,25,26,27,28,29,30,31,32:B1,2,3,4,5,6,7,8,9,10,11,12' 301 | else: 302 | fen = 'W:W31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50:B1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20' 303 | return fen_to_variant(fen, self.variant) 304 | -------------------------------------------------------------------------------- /draughts/__init__.py: -------------------------------------------------------------------------------- 1 | from draughts.core.variant import Board, Move, WHITE, BLACK 2 | 3 | __all__ = ["Board", "Move", "WHITE", "BLACK"] 4 | 5 | __author__ = "Ioannis Pantidis" 6 | __copyright__ = "2021-2025, " + __author__ 7 | __version__ = "0.6.7" 8 | -------------------------------------------------------------------------------- /draughts/ballot_files/2move_english.json: -------------------------------------------------------------------------------- 1 | { 2 | "all": {"01 9-13 21-17": "B:W17,22,23,24,25,26,27,28,29,30,31,32:B1,10,11,12,13,2,3,4,5,6,7,8", "02 9-13 22-17": "B:W17,21,23,24,25,26,27,28,29,30,31,32:B1,10,11,12,13,2,3,4,5,6,7,8", "03 9-13 22-18": "B:W18,21,23,24,25,26,27,28,29,30,31,32:B1,10,11,12,13,2,3,4,5,6,7,8", "04 9-13 23-18": "B:W18,21,22,24,25,26,27,28,29,30,31,32:B1,10,11,12,13,2,3,4,5,6,7,8", "05 9-13 23-19": "B:W19,21,22,24,25,26,27,28,29,30,31,32:B1,10,11,12,13,2,3,4,5,6,7,8", "06 9-13 24-19": "B:W19,21,22,23,25,26,27,28,29,30,31,32:B1,10,11,12,13,2,3,4,5,6,7,8", "07 9-13 24-20": "B:W20,21,22,23,25,26,27,28,29,30,31,32:B1,10,11,12,13,2,3,4,5,6,7,8", "08 9-14 21-17": "B:W17,22,23,24,25,26,27,28,29,30,31,32:B1,10,11,12,14,2,3,4,5,6,7,8", "09 9-14 22-17": "B:W17,21,23,24,25,26,27,28,29,30,31,32:B1,10,11,12,14,2,3,4,5,6,7,8", "10 9-14 22-18": "B:W18,21,23,24,25,26,27,28,29,30,31,32:B1,10,11,12,14,2,3,4,5,6,7,8", "11 9-14 23-18": "B:W18,21,22,24,25,26,27,28,29,30,31,32:B1,10,11,12,14,2,3,4,5,6,7,8", "12 9-14 23-19": "B:W19,21,22,24,25,26,27,28,29,30,31,32:B1,10,11,12,14,2,3,4,5,6,7,8", "13 9-14 24-19": "B:W19,21,22,23,25,26,27,28,29,30,31,32:B1,10,11,12,14,2,3,4,5,6,7,8", "14 9-14 24-20": "B:W20,21,22,23,25,26,27,28,29,30,31,32:B1,10,11,12,14,2,3,4,5,6,7,8", "15 10-14 21-17": "B:W17,22,23,24,25,26,27,28,29,30,31,32:B1,11,12,14,2,3,4,5,6,7,8,9", "16 10-14 22-17": "B:W17,21,23,24,25,26,27,28,29,30,31,32:B1,11,12,14,2,3,4,5,6,7,8,9", "17 10-14 22-18": "B:W18,21,23,24,25,26,27,28,29,30,31,32:B1,11,12,14,2,3,4,5,6,7,8,9", "18 10-14 23-18": "B:W18,21,22,24,25,26,27,28,29,30,31,32:B1,11,12,14,2,3,4,5,6,7,8,9", "19 10-14 23-19": "B:W19,21,22,24,25,26,27,28,29,30,31,32:B1,11,12,14,2,3,4,5,6,7,8,9", "20 10-14 24-19": "B:W19,21,22,23,25,26,27,28,29,30,31,32:B1,11,12,14,2,3,4,5,6,7,8,9", "21 10-14 24-20": "B:W20,21,22,23,25,26,27,28,29,30,31,32:B1,11,12,14,2,3,4,5,6,7,8,9", "22 10-15 21-17": "B:W17,22,23,24,25,26,27,28,29,30,31,32:B1,11,12,15,2,3,4,5,6,7,8,9", "23 10-15 22-17": "B:W17,21,23,24,25,26,27,28,29,30,31,32:B1,11,12,15,2,3,4,5,6,7,8,9", "24 10-15 22-18": "B:W18,21,23,24,25,26,27,28,29,30,31,32:B1,11,12,15,2,3,4,5,6,7,8,9", "25 10-15 23-18": "B:W18,21,22,24,25,26,27,28,29,30,31,32:B1,11,12,15,2,3,4,5,6,7,8,9", "26 10-15 23-19": "B:W19,21,22,24,25,26,27,28,29,30,31,32:B1,11,12,15,2,3,4,5,6,7,8,9", "27 10-15 24-19": "B:W19,21,22,23,25,26,27,28,29,30,31,32:B1,11,12,15,2,3,4,5,6,7,8,9", "28 10-15 24-20": "B:W20,21,22,23,25,26,27,28,29,30,31,32:B1,11,12,15,2,3,4,5,6,7,8,9", "29 11-15 21-17": "B:W17,22,23,24,25,26,27,28,29,30,31,32:B1,10,12,15,2,3,4,5,6,7,8,9", "30 11-15 22-17": "B:W17,21,23,24,25,26,27,28,29,30,31,32:B1,10,12,15,2,3,4,5,6,7,8,9", "31 11-15 22-18": "B:W18,21,23,24,25,26,27,28,29,30,31,32:B1,10,12,15,2,3,4,5,6,7,8,9", "32 11-15 23-18": "B:W18,21,22,24,25,26,27,28,29,30,31,32:B1,10,12,15,2,3,4,5,6,7,8,9", "33 11-15 23-19": "B:W19,21,22,24,25,26,27,28,29,30,31,32:B1,10,12,15,2,3,4,5,6,7,8,9", "34 11-15 24-19": "B:W19,21,22,23,25,26,27,28,29,30,31,32:B1,10,12,15,2,3,4,5,6,7,8,9", "35 11-15 24-20": "B:W20,21,22,23,25,26,27,28,29,30,31,32:B1,10,12,15,2,3,4,5,6,7,8,9", "36 11-16 21-17": "B:W17,22,23,24,25,26,27,28,29,30,31,32:B1,10,12,16,2,3,4,5,6,7,8,9", "37 11-16 22-17": "B:W17,21,23,24,25,26,27,28,29,30,31,32:B1,10,12,16,2,3,4,5,6,7,8,9", "38 11-16 22-18": "B:W18,21,23,24,25,26,27,28,29,30,31,32:B1,10,12,16,2,3,4,5,6,7,8,9", "39 11-16 23-18": "B:W18,21,22,24,25,26,27,28,29,30,31,32:B1,10,12,16,2,3,4,5,6,7,8,9", "40 11-16 23-19": "B:W19,21,22,24,25,26,27,28,29,30,31,32:B1,10,12,16,2,3,4,5,6,7,8,9", "41 11-16 24-19": "B:W19,21,22,23,25,26,27,28,29,30,31,32:B1,10,12,16,2,3,4,5,6,7,8,9", "42 11-16 24-20": "B:W20,21,22,23,25,26,27,28,29,30,31,32:B1,10,12,16,2,3,4,5,6,7,8,9", "43 12-16 21-17": "B:W17,22,23,24,25,26,27,28,29,30,31,32:B1,10,11,16,2,3,4,5,6,7,8,9", "44 12-16 22-17": "B:W17,21,23,24,25,26,27,28,29,30,31,32:B1,10,11,16,2,3,4,5,6,7,8,9", "45 12-16 22-18": "B:W18,21,23,24,25,26,27,28,29,30,31,32:B1,10,11,16,2,3,4,5,6,7,8,9", "46 12-16 23-18": "B:W18,21,22,24,25,26,27,28,29,30,31,32:B1,10,11,16,2,3,4,5,6,7,8,9", "47 12-16 23-19": "B:W19,21,22,24,25,26,27,28,29,30,31,32:B1,10,11,16,2,3,4,5,6,7,8,9", "48 12-16 24-19": "B:W19,21,22,23,25,26,27,28,29,30,31,32:B1,10,11,16,2,3,4,5,6,7,8,9", "49 12-16 24-20": "B:W20,21,22,23,25,26,27,28,29,30,31,32:B1,10,11,16,2,3,4,5,6,7,8,9"}, 3 | "standard": ["01 9-13 21-17", "02 9-13 22-17", "03 9-13 22-18", "04 9-13 23-18", "05 9-13 23-19", "06 9-13 24-19", "07 9-13 24-20", "09 9-14 22-17", "10 9-14 22-18", "12 9-14 23-19", "13 9-14 24-19", "14 9-14 24-20", "16 10-14 22-17", "17 10-14 22-18", "19 10-14 23-19", "20 10-14 24-19", "21 10-14 24-20", "22 10-15 21-17", "23 10-15 22-17", "24 10-15 22-18", "25 10-15 23-18", "26 10-15 23-19", "27 10-15 24-19", "28 10-15 24-20", "29 11-15 21-17", "30 11-15 22-17", "31 11-15 22-18", "32 11-15 23-18", "33 11-15 23-19", "34 11-15 24-19", "35 11-15 24-20", "36 11-16 21-17", "37 11-16 22-17", "38 11-16 22-18", "39 11-16 23-18", "41 11-16 24-19", "42 11-16 24-20", "43 12-16 21-17", "44 12-16 22-17", "45 12-16 22-18", "46 12-16 23-18", "48 12-16 24-19", "49 12-16 24-20"], 4 | "lost": ["08 9-14 21-17", "11 9-14 23-18", "15 10-14 21-17", "18 10-14 23-18", "40 11-16 23-19", "47 12-16 23-19"] 5 | } -------------------------------------------------------------------------------- /draughts/ballot_files/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AttackingOrDefending/pydraughts/3277325ccc17cca75e46da454dc7fb8c0c32150b/draughts/ballot_files/__init__.py -------------------------------------------------------------------------------- /draughts/ballots.py: -------------------------------------------------------------------------------- 1 | # 11 english, 11 italian, 4 move english and 5 move english ballots are from 2 | # Ed Gilbert (http://edgilbert.org/Checkers/KingsRow.htm). 3 | 4 | import random 5 | import json 6 | import os 7 | from typing import Tuple, List, Dict 8 | 9 | 10 | class Ballots: 11 | def __init__(self, variant: str, moves: int = 3, eleven_pieces: bool = False, basic_positions: bool = False, 12 | include_lost_games: bool = False) -> None: 13 | self.variant = variant 14 | self.moves = moves 15 | self.eleven_pieces = eleven_pieces 16 | self.basic_positions = basic_positions 17 | self.include_lost_games = include_lost_games 18 | self.filename = self._find_file() 19 | self.positions, self.keys = self.open_file() 20 | self.keys_to_use = self.keys.copy() 21 | 22 | def _find_file(self) -> str: 23 | """Get the filename of the ballots.""" 24 | if self.variant == 'italian': 25 | return '11_italian.json' 26 | if self.variant == 'english': 27 | if self.eleven_pieces: 28 | return '11_english.json' 29 | if self.moves == 2: 30 | return '2move_english.json' 31 | if self.moves == 4: 32 | return '4move_english.json' 33 | if self.moves == 5: 34 | return '5move_english.json' 35 | if self.basic_positions: 36 | return '150russian_and_brazilian.json' 37 | if self.variant == 'russian': 38 | return 'russian.json' 39 | if self.variant == 'brazilian': 40 | return 'brazilian.json' 41 | return '3move_english.json' 42 | 43 | def open_file(self) -> Tuple[Dict[str, str], List[str]]: 44 | """Open the ballot file.""" 45 | filepath = os.path.join(os.path.dirname(__file__), 'ballot_files', self.filename) 46 | with open(filepath) as file: 47 | data = json.load(file) 48 | keys = data['standard'] + (data.get('lost', []) if self.include_lost_games else []) 49 | return data['all'], keys 50 | 51 | def get_ballot(self) -> str: 52 | """Get one ballot.""" 53 | if not self.keys_to_use: 54 | self.keys_to_use = self.keys.copy() 55 | key = self.keys_to_use.pop(random.randint(0, len(self.keys_to_use) - 1)) 56 | return self.positions[key] 57 | -------------------------------------------------------------------------------- /draughts/convert.py: -------------------------------------------------------------------------------- 1 | from math import ceil 2 | import string 3 | import re 4 | from typing import Tuple, Optional, List 5 | 6 | 7 | def _get_squares(variant: Optional[str]) -> Tuple[int, int, int, bool]: 8 | """Returns the total squares, squares per row, squares per column and if every other square is playable.""" 9 | # The default values are for International draughts. 10 | total_squares = 50 11 | squares_per_row = 5 12 | squares_per_column = 5 13 | every_other_square = True 14 | if variant in ['english', 'italian', 'russian', 'brazilian']: 15 | total_squares = 32 16 | squares_per_row = 4 17 | squares_per_column = 4 18 | elif variant in ['turkish']: 19 | total_squares = 64 20 | squares_per_row = 8 21 | squares_per_column = 8 22 | every_other_square = False 23 | return total_squares, squares_per_row, squares_per_column, every_other_square 24 | 25 | 26 | def _rotate_move(internal_move: str, notation: Optional[int] = None, variant: Optional[str] = None) -> str: 27 | """Rotate the move.""" 28 | separators = ['-', 'x', ':'] 29 | split_move = [] 30 | correct_seperator = '' 31 | for separator in separators: 32 | split_move = internal_move.split(separator) 33 | if split_move[0] != internal_move: 34 | correct_seperator = separator 35 | break 36 | int_move_parts = list(map(int, split_move)) 37 | variant_to_notation = {'standard': 2, 'english': 1, 'italian': 2, 'russian': 0, 'brazilian': 0, 'turkish': 0, 38 | 'frisian': 2, 'frysk!': 2, 'antidraughts': 2, 'breakthrough': 2} 39 | if notation is None: 40 | notation = variant_to_notation.get(variant, 2) 41 | 42 | def reverse_column(split_int_move: List[int]) -> List[int]: 43 | per_row = _get_squares(variant)[1] 44 | for index, square in enumerate(split_int_move): 45 | square_in_row = square % per_row 46 | if square_in_row == 0: 47 | square_in_row += per_row 48 | split_int_move[index] = ((square - 1) // per_row) * per_row + (per_row - (square_in_row - 1)) 49 | return split_int_move 50 | 51 | def reverse_row_and_column(split_int_move: List[int]) -> List[int]: 52 | squares = _get_squares(variant)[0] 53 | split_int_move = list(map(lambda square: squares + 1 - square, split_int_move)) 54 | return split_int_move 55 | 56 | def reverse_row(split_int_move: List[int]) -> List[int]: 57 | return reverse_column(reverse_row_and_column(split_int_move)) 58 | 59 | if notation == 0: 60 | rotated_move = reverse_row(int_move_parts) 61 | elif notation == 1: 62 | rotated_move = reverse_row_and_column(int_move_parts) 63 | elif notation == 2: 64 | rotated_move = int_move_parts 65 | else: # notation == 3 66 | rotated_move = reverse_column(int_move_parts) 67 | 68 | rotated_str_move = list(map(str, rotated_move)) 69 | return correct_seperator.join(rotated_str_move) 70 | 71 | 72 | def _algebraic_to_number(algebraic_move: str, squares_per_letter: Optional[int] = None, variant: Optional[str] = None, 73 | every_other_square: Optional[bool] = None) -> str: 74 | """Convert an algebraic move to a numeric move.""" 75 | if every_other_square is None: 76 | if variant == 'turkish': 77 | every_other_square = False 78 | else: 79 | every_other_square = True 80 | algebraic_notation = algebraic_move[0] in string.ascii_letters 81 | if not algebraic_notation: 82 | return algebraic_move 83 | algebraic_move = algebraic_move.lower() 84 | if squares_per_letter is None: 85 | squares_per_letter = _get_squares(variant)[2] 86 | 87 | separators = ['-', 'x', ':'] 88 | special_seperators = [r'([a-zA-z]+\d+)'] 89 | split_move = [] 90 | correct_seperator = '' 91 | for separator in separators: 92 | split_move = list(filter(bool, re.split(separator, algebraic_move))) 93 | if split_move[0] != algebraic_move: 94 | correct_seperator = separator 95 | break 96 | 97 | if not correct_seperator: 98 | for separator in special_seperators: 99 | split_move = list(filter(bool, re.split(separator, algebraic_move))) 100 | if split_move[0] != algebraic_move: 101 | correct_seperator = '-' 102 | break 103 | 104 | numeric_move = [] 105 | for move in split_move: 106 | numeric_move.append(_algebraic_to_numeric_square(move, squares_per_letter, every_other_square=every_other_square)) 107 | numeric_str_move = list(map(str, numeric_move)) 108 | return correct_seperator.join(numeric_str_move) 109 | 110 | 111 | def _algebraic_to_numeric_square(square: str, squares_per_letter: int, every_other_square: bool = True) -> int: 112 | """Convert an algebraic square to a numeric square.""" 113 | algebraic_notation = square[0] in string.ascii_letters 114 | if not algebraic_notation: 115 | return int(square) 116 | if not every_other_square: 117 | return (int(square[1]) - 1) * squares_per_letter + string.ascii_lowercase.index(square[0]) + 1 118 | return (int(square[1]) - 1) * squares_per_letter + ceil(string.ascii_lowercase.index(square[0]) // 2) + 1 119 | 120 | 121 | def _number_to_algebraic(number_move: str, width: Optional[int] = None, variant: Optional[str] = None, 122 | every_other_square: Optional[bool] = None) -> str: 123 | """Convert a numeric move to an algebraic move.""" 124 | if every_other_square is None: 125 | every_other_square = _get_squares(variant)[3] 126 | algebraic_notation = number_move[0] in string.ascii_letters 127 | if algebraic_notation: 128 | return number_move 129 | if width is None: 130 | width = _get_squares(variant)[1] 131 | 132 | separators = ['-', 'x', ':'] 133 | split_move = [] 134 | correct_seperator = '' 135 | for separator in separators: 136 | split_move = list(filter(bool, re.split(separator, number_move))) 137 | if split_move[0] != number_move: 138 | correct_seperator = separator 139 | break 140 | 141 | algebraic_move = [] 142 | for move_part in split_move: 143 | algebraic_move.append(_numeric_to_algebraic_square(move_part, width, every_other_square=every_other_square)) 144 | return correct_seperator.join(algebraic_move) 145 | 146 | 147 | def _numeric_to_algebraic_square(square: str, width: int, every_other_square: Optional[bool] = True) -> str: 148 | """Convert a numeric square to an algebraic square.""" 149 | algebraic_notation = square[0] in string.ascii_letters 150 | if algebraic_notation: 151 | return square 152 | int_square = int(square) 153 | row = ceil(int_square / width) - 1 154 | column = (int_square - 1) % width 155 | if every_other_square: 156 | column *= 2 157 | column += 1 if row % 2 == 1 else 0 158 | return string.ascii_lowercase[column] + str(row + 1) 159 | 160 | 161 | def _change_fen_from_variant(li_fen: str, notation: Optional[int] = None, squares_per_letter: int = 5, 162 | every_other_square: bool = True, variant: Optional[str] = None) -> str: 163 | """Convert an internal fen to the correct fen for the variant.""" 164 | if variant: 165 | _, _, squares_per_letter, every_other_square = _get_squares(variant) 166 | 167 | fen = li_fen.split(':') 168 | if len(fen) < 3: 169 | return li_fen 170 | starts = fen[0] 171 | white_pieces = fen[1][1:].split(',') 172 | black_pieces = fen[2][1:].split(',') 173 | white_pieces = list(filter(bool, white_pieces)) 174 | black_pieces = list(filter(bool, black_pieces)) 175 | 176 | white_pieces_remove_hyphen = [] 177 | for white_piece in white_pieces: 178 | if '-' in white_piece: 179 | start_end = white_piece.split('-') 180 | add_for_king = '' 181 | if start_end[0][0] == 'K': 182 | add_for_king = 'K' 183 | start_end[0] = start_end[0][1:] 184 | start = _algebraic_to_numeric_square(start_end[0], squares_per_letter, every_other_square) 185 | end = _algebraic_to_numeric_square(start_end[1], squares_per_letter, every_other_square) 186 | for number in range(start, end + 1): 187 | white_pieces_remove_hyphen.append(add_for_king + _rotate_move(str(number), notation=notation, variant=variant)) 188 | else: 189 | add_for_king = '' 190 | if white_piece[0] == 'K': 191 | add_for_king = 'K' 192 | white_piece = white_piece[1:] 193 | white_pieces_remove_hyphen.append( 194 | add_for_king + _rotate_move( 195 | str(_algebraic_to_numeric_square(white_piece, squares_per_letter, every_other_square)), 196 | notation=notation, variant=variant)) 197 | 198 | black_pieces_remove_hyphen = [] 199 | for black_piece in black_pieces: 200 | if '-' in black_piece: 201 | start_end = black_piece.split('-') 202 | add_for_king = '' 203 | if start_end[0][0] == 'K': 204 | add_for_king = 'K' 205 | start_end[0] = start_end[0][1:] 206 | start = _algebraic_to_numeric_square(start_end[0], squares_per_letter, every_other_square) 207 | end = _algebraic_to_numeric_square(start_end[1], squares_per_letter, every_other_square) 208 | for number in range(start, end + 1): 209 | black_pieces_remove_hyphen.append(add_for_king + _rotate_move(str(number), notation=notation, variant=variant)) 210 | else: 211 | add_for_king = '' 212 | if black_piece[0] == 'K': 213 | add_for_king = 'K' 214 | black_piece = black_piece[1:] 215 | black_pieces_remove_hyphen.append( 216 | add_for_king + _rotate_move( 217 | str(_algebraic_to_numeric_square(black_piece, squares_per_letter, every_other_square)), 218 | notation=notation, variant=variant)) 219 | 220 | # Because in english black starts. 221 | white_starts = variant not in ['english'] 222 | if not white_starts: 223 | white_pieces_remove_hyphen, black_pieces_remove_hyphen = black_pieces_remove_hyphen, white_pieces_remove_hyphen 224 | starts = 'W' if starts == 'B' else 'B' 225 | 226 | white_pieces_remove_hyphen.sort() 227 | black_pieces_remove_hyphen.sort() 228 | return f'{starts}:W{",".join(white_pieces_remove_hyphen)}:B{",".join(black_pieces_remove_hyphen)}' 229 | 230 | 231 | def fen_from_variant(fen: str, variant: Optional[str] = None) -> str: 232 | """Convert variant fen to internal fen.""" 233 | variant = variant.lower() if variant else variant 234 | fen = _change_fen_from_variant(fen, variant=variant) 235 | return fen 236 | 237 | 238 | def fen_to_variant(fen: str, variant: Optional[str] = None, to_algebraic: Optional[bool] = None) -> str: 239 | """Convert internal fen to variant fen.""" 240 | variant = variant.lower() if variant else variant 241 | fen = _change_fen_from_variant(fen, variant=variant) 242 | if to_algebraic or variant in ['russian', 'brazilian', 'turkish']: 243 | new_white_pieces = [] 244 | new_black_pieces = [] 245 | white_pieces = fen.split(':')[1][1:].split(',') 246 | black_pieces = fen.split(':')[2][1:].split(',') 247 | white_pieces = list(filter(bool, white_pieces)) 248 | black_pieces = list(filter(bool, black_pieces)) 249 | for piece in white_pieces: 250 | add = '' 251 | if piece.lower().startswith('k'): 252 | add = 'K' 253 | piece = piece[1:] 254 | new_white_pieces.append(add + _number_to_algebraic(piece, variant=variant)) 255 | for piece in black_pieces: 256 | add = '' 257 | if piece.lower().startswith('k'): 258 | add = 'K' 259 | piece = piece[1:] 260 | new_black_pieces.append(add + _number_to_algebraic(piece, variant=variant)) 261 | fen = f'{fen[0]}:W{",".join(new_white_pieces)}:B{",".join(new_black_pieces)}' 262 | return fen 263 | 264 | 265 | def move_from_variant(move: str, variant: Optional[str] = None) -> str: 266 | """Convert variant PDN move to internal PDN move.""" 267 | variant = variant.lower() if variant else variant 268 | move = _algebraic_to_number(move, variant=variant) 269 | move = _rotate_move(move, variant=variant) 270 | return move 271 | 272 | 273 | def move_to_variant(move: str, variant: Optional[str] = None, to_algebraic: Optional[bool] = None) -> str: 274 | """Convert internal PDN move to variant PDN move.""" 275 | variant = variant.lower() if variant else variant 276 | move = _rotate_move(move, variant=variant) 277 | if to_algebraic is not False and variant in ['russian', 'brazilian', 'turkish']: 278 | move = _number_to_algebraic(move, variant=variant) 279 | return move 280 | -------------------------------------------------------------------------------- /draughts/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AttackingOrDefending/pydraughts/3277325ccc17cca75e46da454dc7fb8c0c32150b/draughts/core/__init__.py -------------------------------------------------------------------------------- /draughts/core/board.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from draughts.core.board_searcher import BoardSearcher 3 | from draughts.core.board_initializer import BoardInitializer 4 | from draughts.core.piece import Piece 5 | from functools import reduce 6 | import pickle 7 | from typing import Optional, List, Tuple, Any, Dict 8 | 9 | WHITE = 2 10 | BLACK = 1 11 | 12 | 13 | class Board: 14 | 15 | def __init__(self, variant: str = 'standard', fen: str = 'startpos') -> None: 16 | if fen != 'startpos': 17 | self.player_turn = WHITE if fen[0].lower() == 'w' else BLACK 18 | else: 19 | self.player_turn = WHITE 20 | 21 | if variant in ['brazilian', 'russian', 'english', 'italian']: 22 | self.width = 4 23 | self.height = 8 24 | elif variant == 'turkish': 25 | self.width = 8 26 | self.height = 8 27 | else: 28 | self.width = 5 29 | self.height = 10 30 | 31 | if variant == 'frysk!': 32 | self.rows_per_user_with_pieces = 1 33 | elif variant == 'turkish': 34 | self.rows_per_user_with_pieces = 2 35 | elif variant in ['brazilian', 'russian', 'english', 'italian']: 36 | self.rows_per_user_with_pieces = 3 37 | else: 38 | self.rows_per_user_with_pieces = 4 39 | 40 | self.position_count = self.width * self.height 41 | self.position_layout: Dict[int, Dict[int, int]] = {} 42 | self.piece_requiring_further_capture_moves: Optional[Piece] = None 43 | self.previous_move_was_capture = False 44 | self.variant = variant 45 | self.fen = fen 46 | self.searcher = BoardSearcher() 47 | BoardInitializer(self, self.fen).initialize() 48 | 49 | self.pieces_promote_and_stop_capturing = self.variant in ['english', 'italian'] 50 | self.pieces_promote_and_continue_capturing = self.variant in ['russian'] 51 | 52 | def count_movable_player_pieces(self, player_number: int = 1, captures: Optional[List[int]] = None) -> int: 53 | """Count the pieces of one player that can be moved.""" 54 | if captures is None: 55 | captures = [] 56 | return reduce((lambda count, piece: count + (1 if piece.is_movable(captures) else 0)), 57 | self.searcher.get_pieces_by_player(player_number), 0) 58 | 59 | def get_possible_moves(self, captures: List[int]) -> List[List[int]]: 60 | """Get all possible moves.""" 61 | capture_moves = self.get_possible_capture_moves(captures) 62 | 63 | return capture_moves if capture_moves else self.get_possible_positional_moves() 64 | 65 | def get_possible_capture_moves(self, captures: List[int]) -> List[List[int]]: 66 | """Get all possible capture moves (not positional moves).""" 67 | return reduce((lambda moves, piece: moves + piece.get_possible_capture_moves(captures)), 68 | self.searcher.get_pieces_in_play(), []) 69 | 70 | def get_possible_positional_moves(self) -> List[List[int]]: 71 | """Get all possible positional moves (not capture moves).""" 72 | return reduce((lambda moves, piece: moves + piece.get_possible_positional_moves()), 73 | self.searcher.get_pieces_in_play(), []) 74 | 75 | def position_is_open(self, position: int) -> bool: 76 | """Get if the position is open (a piece is not in the given square).""" 77 | return position in self.searcher.open_positions 78 | 79 | def create_new_board_from_move(self, move: List[int], move_number: int, captures: List[int] 80 | ) -> Tuple[Board, Optional[int]]: 81 | """Create a new board and play the move given.""" 82 | new_board = pickle.loads(pickle.dumps(self, -1)) # A lot faster that deepcopy. 83 | enemy_position = None 84 | 85 | if move in self.get_possible_capture_moves(captures): 86 | enemy_position = new_board.perform_capture_move(move, move_number, captures) 87 | else: 88 | new_board.perform_positional_move(move, move_number) 89 | 90 | return new_board, enemy_position 91 | 92 | def push_move(self, move: List[int], move_number: int, captures: List[int]) -> Tuple[Board, Optional[int]]: 93 | """Play the move given without creating a new board.""" 94 | # It takes 40% less time than create_new_board_from_move (60% faster). 95 | enemy_position = None 96 | 97 | if move in self.get_possible_capture_moves(captures): 98 | enemy_position = self.perform_capture_move(move, move_number, captures) 99 | else: 100 | self.perform_positional_move(move, move_number) 101 | 102 | return self, enemy_position 103 | 104 | def perform_capture_move(self, move: List[int], move_number: int, captures: List[int]) -> Optional[int]: 105 | """Make a capture move.""" 106 | self.previous_move_was_capture = True 107 | piece = self.searcher.get_piece_by_position(move[0]) 108 | originally_was_king = piece.king 109 | enemy_piece = piece.capture_move_enemies[move[1]] 110 | enemy_position = enemy_piece.position 111 | enemy_piece.capture() 112 | self.move_piece(piece, move[1], move_number) 113 | if not originally_was_king and piece.king and self.pieces_promote_and_stop_capturing: 114 | further_capture_moves_for_piece = [] 115 | elif not originally_was_king and not self.pieces_promote_and_continue_capturing: 116 | was_king = piece.king 117 | piece.king = False 118 | further_capture_moves_for_piece = [capture_move for capture_move in self.get_possible_capture_moves( 119 | captures + [enemy_position]) if move[1] == capture_move[0]] 120 | if not further_capture_moves_for_piece and was_king: 121 | piece.king = True 122 | else: 123 | further_capture_moves_for_piece = [capture_move for capture_move in self.get_possible_capture_moves( 124 | captures + [enemy_position]) if move[1] == capture_move[0]] 125 | 126 | if further_capture_moves_for_piece: 127 | self.piece_requiring_further_capture_moves = self.searcher.get_piece_by_position(move[1]) 128 | else: 129 | self.piece_requiring_further_capture_moves = None 130 | self.switch_turn() 131 | return enemy_position 132 | 133 | def perform_positional_move(self, move: List[int], move_number: int) -> None: 134 | """Make a positional move.""" 135 | self.previous_move_was_capture = False 136 | piece = self.searcher.get_piece_by_position(move[0]) 137 | self.move_piece(piece, move[1], move_number) 138 | self.switch_turn() 139 | 140 | def switch_turn(self) -> None: 141 | """Switch the turn.""" 142 | self.player_turn = BLACK if self.player_turn == WHITE else WHITE 143 | 144 | def move_piece(self, piece: Piece, to: int, move_number: int) -> None: 145 | """Move a piece.""" 146 | piece.move(to, move_number) 147 | self.pieces = sorted(self.pieces, key=lambda piece: piece.position if not piece.captured else 0) 148 | 149 | def is_valid_row_and_column(self, row: int, column: int) -> bool: 150 | """Get if the given row and column is inside the board.""" 151 | if row < 0 or row >= self.height: 152 | return False 153 | 154 | if column < 0 or column >= self.width: 155 | return False 156 | 157 | return True 158 | 159 | def __setattr__(self, name: str, value: Any) -> None: 160 | super(Board, self).__setattr__(name, value) 161 | 162 | if name == 'pieces': 163 | [piece.reset_for_new_board() for piece in self.pieces] 164 | 165 | self.searcher.build(self) 166 | -------------------------------------------------------------------------------- /draughts/core/board_initializer.py: -------------------------------------------------------------------------------- 1 | from draughts.core.piece import Piece 2 | from typing import Any 3 | 4 | WHITE = 2 5 | BLACK = 1 6 | 7 | 8 | class BoardInitializer: 9 | 10 | def __init__(self, board: Any, fen: str = 'startpos') -> None: 11 | self.board = board 12 | self.fen = fen 13 | 14 | def initialize(self) -> None: 15 | """Initialize the board.""" 16 | self.build_position_layout() 17 | self.set_starting_pieces() 18 | 19 | def build_position_layout(self) -> None: 20 | """Build the position layout.""" 21 | self.board.position_layout = {} 22 | position = 1 23 | 24 | for row in range(self.board.height): 25 | self.board.position_layout[row] = {} 26 | 27 | for column in range(self.board.width): 28 | self.board.position_layout[row][column] = position 29 | position += 1 30 | 31 | def set_starting_pieces(self) -> None: 32 | """Create the pieces.""" 33 | pieces = [] 34 | if self.fen != 'startpos': # Hub fen 35 | # starting = self.fen[0] 36 | board = self.fen[1:] 37 | for index, position in enumerate(board): 38 | piece = None 39 | if position.lower() == 'w': 40 | # Index + 1 because enumerate returns 0-49 while the board takes 1-50. 41 | piece = self.create_piece(WHITE, index + 1) 42 | if position == 'W': 43 | piece.king = True 44 | elif position.lower() == 'b': 45 | piece = self.create_piece(BLACK, index + 1) 46 | if position == 'B': 47 | piece.king = True 48 | if piece: 49 | pieces.append(piece) 50 | else: # Not used, but it isn't removed, because it may be needed in the future 51 | starting_piece_count = self.board.width * self.board.rows_per_user_with_pieces 52 | player_starting_positions = { 53 | 1: list(range(1, starting_piece_count + 1)), 54 | 2: list(range(self.board.position_count - starting_piece_count + 1, self.board.position_count + 1)) 55 | } 56 | 57 | for key, row in self.board.position_layout.items(): 58 | for key, position in row.items(): 59 | player_number = 1 if position in player_starting_positions[1] else ( 60 | 2 if position in player_starting_positions[2] else None) 61 | 62 | if player_number: 63 | pieces.append(self.create_piece(player_number, position)) 64 | 65 | self.board.pieces = pieces 66 | 67 | def create_piece(self, player_number: int, position: int) -> Piece: 68 | """Create a piece.""" 69 | piece = Piece(position, player_number, self.board, variant=self.board.variant) 70 | 71 | return piece 72 | -------------------------------------------------------------------------------- /draughts/core/board_searcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from functools import reduce 3 | from typing import List, Dict, Any 4 | from draughts.core.piece import Piece 5 | 6 | WHITE = 2 7 | BLACK = 1 8 | 9 | 10 | class BoardSearcher: 11 | 12 | def build(self, board: Any) -> None: 13 | """Build the searcher.""" 14 | self.board = board 15 | self.uncaptured_pieces = list(filter(lambda piece: not piece.captured, board.pieces)) 16 | self.open_positions: List[int] = [] 17 | self.filled_positions: List[int] = [] 18 | self.player_positions: Dict[int, List[int]] = {} 19 | self.player_pieces: Dict[int, List[Piece]] = {} 20 | self.position_pieces: Dict[int, Piece] = {} 21 | 22 | self.build_filled_positions() 23 | self.build_open_positions() 24 | self.build_player_positions() 25 | self.build_player_pieces() 26 | self.build_position_pieces() 27 | 28 | def build_filled_positions(self) -> None: 29 | """Find the filled positions (squares which have a piece).""" 30 | self.filled_positions = reduce((lambda open_positions, piece: open_positions + [piece.position]), 31 | self.uncaptured_pieces, []) 32 | 33 | def build_open_positions(self) -> None: 34 | """Find the open positions (empty squares).""" 35 | self.open_positions = list(set(range(1, self.board.position_count + 1)).difference(self.filled_positions)) 36 | 37 | def build_player_positions(self) -> None: 38 | """Find the positions where each player has a piece.""" 39 | self.player_positions = { 40 | 1: reduce((lambda positions, piece: positions + ([piece.position] if piece.player == BLACK else [])), 41 | self.uncaptured_pieces, []), 42 | 2: reduce((lambda positions, piece: positions + ([piece.position] if piece.player == WHITE else [])), 43 | self.uncaptured_pieces, []) 44 | } 45 | 46 | def build_player_pieces(self) -> None: 47 | """Find all the pieces of both players.""" 48 | self.player_pieces = { 49 | BLACK: reduce((lambda pieces, piece: pieces + ([piece] if piece.player == BLACK else [])), 50 | self.uncaptured_pieces, []), 51 | WHITE: reduce((lambda pieces, piece: pieces + ([piece] if piece.player == WHITE else [])), 52 | self.uncaptured_pieces, []) 53 | } 54 | 55 | def build_position_pieces(self) -> None: 56 | """Make a dict where the key is the square and the value is the piece in this square.""" 57 | self.position_pieces = {piece.position: piece for piece in self.uncaptured_pieces} 58 | 59 | def get_pieces_by_player(self, player_number: int) -> List[Piece]: 60 | """Get all the pieces of one player.""" 61 | return self.player_pieces[player_number] 62 | 63 | def get_positions_by_player(self, player_number: int) -> List[int]: 64 | """Get the positions of one player's pieces.""" 65 | return self.player_positions[player_number] 66 | 67 | def get_pieces_in_play(self) -> List[Piece]: 68 | """ 69 | Get pieces in play. They are: All the pieces of the player playing now except when a piece is 70 | in the middle of a multi-capture, so it has already captured one or more pieces, but it can capture more, 71 | where we only return the piece that is in the middle of the multi-capture. 72 | """ 73 | return (self.player_pieces[self.board.player_turn] if not self.board.piece_requiring_further_capture_moves else 74 | [self.board.piece_requiring_further_capture_moves]) 75 | 76 | def get_piece_by_position(self, position: int) -> Piece: 77 | """Get the piece given its position.""" 78 | return self.position_pieces[position] 79 | -------------------------------------------------------------------------------- /draughts/engine.py: -------------------------------------------------------------------------------- 1 | from draughts.engines.dxp import DXPEngine 2 | from draughts.engines.hub import HubEngine 3 | from draughts.engines.checkerboard import CheckerBoardEngine 4 | from typing import Optional, Union, Dict, Any 5 | from draughts.core.variant import Move 6 | 7 | 8 | class Limit: 9 | """Conditions on when the engine should stop searching.""" 10 | def __init__(self, time: Union[int, float, None] = None, inc: Union[int, float, None] = None, 11 | depth: Optional[int] = None, nodes: Optional[int] = None, movetime: Union[int, float, None] = None): 12 | assert time is not None or depth is not None or nodes is not None or movetime is not None 13 | self.time = time 14 | self.inc = inc 15 | self.depth = depth 16 | self.nodes = nodes 17 | self.movetime = movetime 18 | 19 | 20 | class PlayResult: 21 | """The outcome of the engine search.""" 22 | def __init__(self, move: Optional[Move] = None, ponder: Optional[Move] = None, info: Optional[Dict[str, Any]] = None, 23 | draw_offered: bool = False, resigned: bool = False): 24 | self.move = move 25 | self.ponder = ponder 26 | self.info = info 27 | self.draw_offered = draw_offered 28 | self.resigned = resigned 29 | 30 | 31 | __all__ = ['HubEngine', 'DXPEngine', 'CheckerBoardEngine', 'Limit', 'PlayResult'] 32 | -------------------------------------------------------------------------------- /draughts/engines/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AttackingOrDefending/pydraughts/3277325ccc17cca75e46da454dc7fb8c0c32150b/draughts/engines/__init__.py -------------------------------------------------------------------------------- /draughts/engines/checkerboard.py: -------------------------------------------------------------------------------- 1 | from draughts.engines.checkerboard_extra.engine_64 import Engine64 2 | from draughts.engines.checkerboard_extra.engine_client import Engine32 3 | import os 4 | import draughts 5 | import draughts.engine 6 | import logging 7 | from draughts.convert import move_to_variant 8 | from typing import Union, List, Any, Dict, Tuple 9 | 10 | logger = logging.getLogger("pydraughts") 11 | 12 | 13 | class CheckerBoardEngine: 14 | def __init__(self, command: Union[List[str], str], divide_time_by: int = 40, checkerboard_timing: bool = False, 15 | ENGINE: int = 5) -> None: 16 | if type(command) == str: 17 | command = [command] 18 | command = list(filter(bool, command)) 19 | command = " ".join(command) 20 | if "\\" not in command and "/" not in command: 21 | self.cwd = os.path.realpath(os.path.expanduser(".")) 22 | self.command = os.path.join(self.cwd, command) 23 | else: 24 | self.command = command 25 | self.ENGINE = ENGINE 26 | self.info = "" 27 | self.id = {} 28 | self.result = None 29 | self.divide_time_by = divide_time_by 30 | self.checkerboard_timing = checkerboard_timing 31 | self._sent_variant = False 32 | self.engine: Union[Engine64, Engine32] 33 | self.engine, self.bits = self._open_engine() 34 | self.id["name"] = self.engine.enginecommand('name')[0].decode() 35 | 36 | def _open_engine(self) -> Union[Tuple[Engine64, int], Tuple[Engine32, int]]: 37 | """Open the engine process.""" 38 | try: 39 | return Engine64(self.command), 64 40 | except Exception: 41 | return Engine32(self.command), 32 42 | 43 | def setoption(self, name: str, value: Union[str, int]) -> None: 44 | """Set an engine option.""" 45 | if name == 'divide-time-by': 46 | self.divide_time_by = int(value) 47 | else: 48 | self.engine.enginecommand(f"set {name} {value}") 49 | 50 | def configure(self, options: Dict[str, Union[str, int]]) -> None: 51 | """Configure many options at once.""" 52 | for name, value in options.items(): 53 | self.setoption(name, value) 54 | 55 | def kill_process(self) -> None: 56 | """Kill the engine process.""" 57 | if self.bits == 32: 58 | self.engine.shutdown_server32() 59 | else: 60 | self.engine.kill_process() 61 | 62 | def play(self, board: draughts.Board, time_limit: Any) -> Any: 63 | """Engine search.""" 64 | time = time_limit.time 65 | inc = time_limit.inc 66 | depth = time_limit.depth 67 | nodes = time_limit.nodes 68 | movetime = time_limit.movetime 69 | 70 | if not inc: 71 | inc = 0 72 | 73 | if not self._sent_variant: 74 | if board.variant == 'russian': 75 | self.engine.enginecommand('set gametype 25') 76 | elif board.variant == 'brazilian': 77 | self.engine.enginecommand('set gametype 26') 78 | elif board.variant == 'italian': 79 | self.engine.enginecommand('set gametype 22') 80 | elif board.variant == 'english': 81 | self.engine.enginecommand('set gametype 21') 82 | self._sent_variant = True 83 | 84 | if board.move_stack: 85 | reversible_moves = " ".join(list(map(lambda move: move.pdn_move, board._reversible_moves))) 86 | gamehist = f'set gamehist {board._last_non_reversible_fen} {reversible_moves}' 87 | if len(gamehist) > 256: 88 | gamehist = " ".join(gamehist[-256:].split()[1:]) 89 | self.engine.enginecommand(gamehist) 90 | 91 | time_to_use = None 92 | if time: 93 | 94 | if time < 0 and inc < 0: 95 | time = 0 96 | inc = 0 97 | elif time < 0: 98 | inc = max(inc + time, 0) 99 | time = 0 100 | elif inc < 0: 101 | time = max(time + inc, 0) 102 | inc = 0 103 | 104 | if self.checkerboard_timing: 105 | if time < inc * .4: 106 | time_to_use = inc * .4 107 | elif time < inc: 108 | time_to_use = time 109 | else: 110 | time_to_use = inc + (time - inc) / 2.5 111 | else: 112 | time_to_use = time / self.divide_time_by 113 | 114 | logger.debug(f"Fen: {board.fen}, Time to use: {time_to_use}, Time: {time}, Inc: {inc}, Movetime: {movetime}") 115 | 116 | hub_pos_move, info, cbmove, result = self.engine.getmove(board, time_to_use, time, inc, movetime) 117 | 118 | logger.debug(f"Hub Pos Move: {hub_pos_move}, CBMove: {cbmove}, Info: {info.decode()}, Result: {result}") 119 | 120 | if hub_pos_move: 121 | hub_move = '-'.join([hub_pos_move[i:i+2] for i in range(0, len(hub_pos_move), 2)]) 122 | bestmove = draughts.Move(board, hub_move=move_to_variant(hub_move, board.variant, to_algebraic=False)) 123 | else: 124 | steps = [] 125 | positions = [cbmove['from']] 126 | jumps = max(cbmove['jumps'], 1) 127 | for pos in cbmove['path'][1:jumps]: 128 | positions.append(pos) 129 | positions.append(cbmove['to']) 130 | for pos in positions: 131 | # Checkerboard returns first the column, then the row 132 | steps.append(self._row_col_to_num(board, pos[1], pos[0])) 133 | 134 | steps = list(map(lambda step: int(move_to_variant(str(step), board.variant, to_algebraic=False)), steps)) 135 | bestmove = draughts.Move(board, steps_move=steps) 136 | 137 | self.info = info.decode() 138 | self.result = result 139 | return draughts.engine.PlayResult(bestmove, None, {'info': self.info, 'result': self.result}) 140 | 141 | def _row_col_to_num(self, board: draughts.Board, row: int, col: int) -> int: 142 | """Get the square from the row and column.""" 143 | if row % 2 == 0: 144 | col = int(((col + 2) / 2) - 1) 145 | else: 146 | col = int(((col + 1) / 2) - 1) 147 | # Because: 148 | # 1. In italian the bottom-left square isn't playable, so in CheckerBoard the board is flipped vertically. 149 | # 2. In most variants the bottom-left square for the starting side (usually white) is in column a, 150 | # while in english black starts, so the bottom-left square for the starting side (black) is in row h. 151 | flip_column = board.variant not in ['english', 'italian'] 152 | if flip_column: 153 | col = board._game.board.width - 1 - col 154 | # Because in english black starts 155 | white_starts = board.variant not in ['english'] 156 | if not white_starts: 157 | row = (board._game.board.height - 1) - row 158 | loc = board._game.board.position_layout.get(row, {}).get(col) 159 | assert isinstance(loc, int) 160 | return loc 161 | -------------------------------------------------------------------------------- /draughts/engines/checkerboard_extra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AttackingOrDefending/pydraughts/3277325ccc17cca75e46da454dc7fb8c0c32150b/draughts/engines/checkerboard_extra/__init__.py -------------------------------------------------------------------------------- /draughts/engines/checkerboard_extra/engine_64.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | from ctypes import wintypes 3 | import os 4 | import draughts 5 | from draughts.engines.checkerboard_extra.get_checker_board import get_board, from_board 6 | from typing import Optional, Union, Tuple, Dict, Any 7 | 8 | 9 | class Engine64: 10 | def __init__(self, command: str) -> None: 11 | os.add_dll_directory(os.path.realpath(os.path.expanduser(os.path.dirname(command)))) 12 | self.engine = ctypes.windll.LoadLibrary(command) 13 | 14 | def kill_process(self) -> None: 15 | """Kill the engine process.""" 16 | handle = self.engine._handle 17 | try: 18 | # Windows 19 | ctypes.windll.kernel32.FreeLibrary.argtypes = [wintypes.HMODULE] 20 | ctypes.windll.kernel32.FreeLibrary(handle) 21 | except Exception: 22 | # Unix 23 | self.engine.dlcose(handle) 24 | 25 | def enginecommand(self, command: str) -> Tuple[bytes, int]: 26 | """Send an enginecommand to the engine.""" 27 | output = ctypes.create_string_buffer(b'', 1024) 28 | result = self.engine.enginecommand(ctypes.create_string_buffer(bytes(command.encode('ascii')), 256), output) 29 | return output.value, result 30 | 31 | def getmove(self, game: draughts.Board, maxtime: Union[int, float, None] = None, time: Union[int, float, None] = None, 32 | increment: Union[int, float, None] = None, movetime: Union[int, float, None] = None 33 | ) -> Tuple[Optional[str], bytes, Dict[str, Any], int]: 34 | """Send a getmove to the engine.""" 35 | assert maxtime is not None or time is not None or movetime is not None 36 | 37 | # From CheckerBoard API: 38 | WHITE = 1 39 | BLACK = 2 40 | 41 | board = get_board(game) 42 | 43 | # Reversed color because red (black) starts first and not white in english checkers in Checkerboard. 44 | color = WHITE if game.turn == draughts.WHITE else BLACK 45 | 46 | info = 0 47 | moreinfo = 0 48 | if movetime: 49 | info = info | (1 << 1) # 2nd bit means the engine has to think for exactly maxtime seconds 50 | elif time is not None and increment is not None: 51 | if time / .01 > 2 ** 15 - 1 or increment / .01 > 2 ** 15 - 1: 52 | info = info | (1 << 3) # 0.1 seconds 53 | info = info | (1 << 4) # 0.1 seconds 54 | time = int(time / .1) 55 | increment = int(increment / .1) 56 | elif time / .001 > 2 ** 15 - 1 or increment / .001 > 2 ** 15 - 1: 57 | info = info | (1 << 3) # 0.01 seconds 58 | time = int(time / .01) 59 | increment = int(increment / .01) 60 | else: 61 | info = info | (1 << 4) # 0.001 seconds 62 | time = int(time / .001) 63 | increment = int(increment / .001) 64 | bin_time = bin(time)[2:].zfill(16) 65 | if len(bin_time) > 16: 66 | bin_time = '1' * 16 67 | bin_inc = bin(increment)[2:].zfill(16) 68 | if len(bin_inc) > 16: 69 | bin_inc = '1' * 16 70 | moreinfo = eval('0b' + bin_time + bin_inc) 71 | 72 | if movetime is not None: 73 | maxtime_double = ctypes.c_double(float(movetime)) 74 | else: 75 | assert maxtime is not None 76 | maxtime_double = ctypes.c_double(float(maxtime)) 77 | output = ctypes.create_string_buffer(b'', 1024) 78 | playnow = ctypes.c_int(0) 79 | 80 | class coor(ctypes.Structure): 81 | _fields_ = [("x", ctypes.c_int), ("y", ctypes.c_int)] 82 | 83 | class CBmove(ctypes.Structure): 84 | _fields_ = [("jumps", ctypes.c_int), ("newpiece", ctypes.c_int), ("oldpiece", ctypes.c_int), ("from", coor), 85 | ("to", coor), ("path", coor * 12), ("del", coor * 12), ("delpiece", ctypes.c_int * 12)] 86 | 87 | # self.engine.argtypes = [((ctypes.c_int * 8) * 8), ctypes.c_int, ctypes.c_double, (ctypes.c_char * 1024), 88 | # ctypes.POINTER(ctypes.c_int), ctypes.c_int, ctypes.c_int, ctypes.POINTER(CBmove)] 89 | 90 | cbmove = CBmove() 91 | 92 | result = self.engine.getmove(board, color, maxtime_double, output, ctypes.byref(playnow), info, moreinfo, 93 | ctypes.byref(cbmove)) 94 | 95 | old_fen = game._game.get_fen() 96 | new_fen = from_board(board, game) 97 | our_pieces, opponents_pieces = (['w', 'W'], ['b', 'B']) if old_fen[0] == 'W' else (['b', 'B'], ['w', 'W']) 98 | captures = [] 99 | start_pos, end_pos = None, None 100 | for index in range(1, len(old_fen)): 101 | if old_fen[index] in our_pieces and new_fen[index] == 'e': 102 | start_pos = index 103 | elif new_fen[index] in our_pieces and old_fen[index] == 'e': 104 | end_pos = index 105 | elif old_fen[index] in opponents_pieces and new_fen[index] == 'e': 106 | captures.append(index) 107 | hub_pos_move = None 108 | if start_pos and end_pos: 109 | hub_pos_move = game._game.make_len_2(start_pos) + game._game.make_len_2(end_pos) + game._game.sort_captures( 110 | captures) 111 | 112 | cbmove_output_2 = {} 113 | cbmove_output_2['jumps'] = cbmove.jumps 114 | cbmove_output_2['oldpiece'] = cbmove.oldpiece 115 | cbmove_output_2['newpiece'] = cbmove.newpiece 116 | cbmove_output_2['to'] = cbmove.to.x, cbmove.to.y 117 | cbmove.to = getattr(cbmove, 'from') 118 | cbmove_output_2['from'] = cbmove.to.x, cbmove.to.y 119 | cbmove_output_2['path'] = [(cbmove.path[i].x, cbmove.path[i].y) for i in range(12)] 120 | cbmove.path = getattr(cbmove, 'del') 121 | cbmove_output_2['del'] = [(cbmove.path[i].x, cbmove.path[i].y) for i in range(12)] 122 | cbmove_output_2['delpiece'] = [cbmove.delpiece[i] for i in range(12)] 123 | return hub_pos_move, output.value, cbmove_output_2, result 124 | -------------------------------------------------------------------------------- /draughts/engines/checkerboard_extra/engine_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, Union, Tuple, Dict, Any 3 | import draughts 4 | 5 | from msl.loadlib import Client64 6 | 7 | 8 | class Engine32(Client64): 9 | """64 bit client to communicate with the 32 bit server that is running the old Checkerboard engine.""" 10 | def __init__(self, command: str) -> None: 11 | 12 | command = command.replace('\\', '\\\\') 13 | 14 | super(Engine32, self).__init__(module32='engine_server', append_sys_path=os.path.dirname(__file__), timeout=15., 15 | dll_name=command) 16 | 17 | def enginecommand(self, command: str) -> Tuple[bytes, int]: 18 | """Send an enginecommand to the engine.""" 19 | response: Tuple[bytes, int] = self.request32('enginecommand', command) 20 | return response 21 | 22 | def getmove(self, game: draughts.Board, maxtime: Union[int, float, None] = None, time: Union[int, float, None] = None, 23 | increment: Union[int, float, None] = None, movetime: Union[int, float, None] = None 24 | ) -> Tuple[Optional[str], bytes, Dict[str, Any], int]: 25 | """Send a getmove to the engine.""" 26 | assert maxtime is not None or time is not None or movetime is not None 27 | response: Tuple[Optional[str], bytes, Dict[str, Any], int] = self.request32('getmove', game, maxtime, time, 28 | increment, movetime) 29 | return response 30 | -------------------------------------------------------------------------------- /draughts/engines/checkerboard_extra/engine_server.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import draughts 3 | from draughts.engines.checkerboard_extra.get_checker_board import get_board, from_board 4 | from typing import Tuple, Optional, Union, Dict, Any 5 | 6 | from msl.loadlib import Server32 7 | 8 | 9 | class Engine32Server(Server32): 10 | """32 bit server to run old Checkerboard engines.""" 11 | 12 | def __init__(self, host: str, port: int, **kwargs: Any) -> None: 13 | super(Engine32Server, self).__init__(kwargs["dll_name"], 'windll', host, port) 14 | 15 | def enginecommand(self, command: str) -> Tuple[bytes, int]: 16 | """Send an enginecommand to the engine.""" 17 | output = ctypes.create_string_buffer(b'', 1024) 18 | result = self.lib.enginecommand(ctypes.create_string_buffer(bytes(command.encode('ascii')), 256), output) 19 | return output.value, result 20 | 21 | def getmove(self, game: draughts.Board, maxtime: Union[int, float, None] = None, time: Union[int, float, None] = None, 22 | increment: Union[int, float, None] = None, movetime: Union[int, float, None] = None 23 | ) -> Tuple[Optional[str], bytes, Dict[str, Any], int]: 24 | """Send a getmove to the engine.""" 25 | 26 | # From CheckerBoard API: 27 | WHITE = 1 28 | BLACK = 2 29 | 30 | board = get_board(game) 31 | 32 | # Reversed color because red (black) starts first and not white in english checkers in Checkerboard. 33 | color = WHITE if game.turn == draughts.WHITE else BLACK 34 | 35 | info = 0 36 | moreinfo = 0 37 | if movetime: 38 | info = info | (1 << 1) # 2nd bit means the engine has to think for exactly maxtime seconds 39 | elif time is not None and increment is not None: 40 | if time / .01 > 2 ** 15 - 1 or increment / .01 > 2 ** 15 - 1: 41 | info = info | (1 << 3) # 0.1 seconds 42 | info = info | (1 << 4) # 0.1 seconds 43 | time = int(time / .1) 44 | increment = int(increment / .1) 45 | elif time / .001 > 2 ** 15 - 1 or increment / .001 > 2 ** 15 - 1: 46 | info = info | (1 << 3) # 0.01 seconds 47 | time = int(time / .01) 48 | increment = int(increment / .01) 49 | else: 50 | info = info | (1 << 4) # 0.001 seconds 51 | time = int(time / .001) 52 | increment = int(increment / .001) 53 | bin_time = bin(time)[2:].zfill(16) 54 | if len(bin_time) > 16: 55 | bin_time = '1' * 16 56 | bin_inc = bin(increment)[2:].zfill(16) 57 | if len(bin_inc) > 16: 58 | bin_inc = '1' * 16 59 | moreinfo = eval('0b' + bin_time + bin_inc) 60 | 61 | if movetime is not None: 62 | maxtime_double = ctypes.c_double(float(movetime)) 63 | else: 64 | assert maxtime is not None 65 | maxtime_double = ctypes.c_double(float(maxtime)) 66 | output = ctypes.create_string_buffer(b'', 1024) 67 | playnow = ctypes.c_int(0) 68 | 69 | class coor(ctypes.Structure): 70 | _fields_ = [("x", ctypes.c_int), ("y", ctypes.c_int)] 71 | 72 | class CBmove(ctypes.Structure): 73 | _fields_ = [("jumps", ctypes.c_int), ("newpiece", ctypes.c_int), ("oldpiece", ctypes.c_int), ("from", coor), 74 | ("to", coor), ("path", coor * 12), ("del", coor * 12), ("delpiece", ctypes.c_int * 12)] 75 | 76 | # self.lib.getmove.argtypes = [((ctypes.c_int * 8) * 8), ctypes.c_int, ctypes.c_double, (ctypes.c_char * 1024), 77 | # ctypes.POINTER(ctypes.c_int), ctypes.c_int, ctypes.c_int, ctypes.POINTER(CBmove)] 78 | 79 | cbmove = CBmove() 80 | 81 | result = self.lib.getmove(board, color, maxtime_double, output, ctypes.byref(playnow), info, moreinfo, 82 | ctypes.byref(cbmove)) 83 | 84 | old_fen = game._game.get_fen() 85 | new_fen = from_board(board, game) 86 | our_pieces, opponents_pieces = (['w', 'W'], ['b', 'B']) if old_fen[0] == 'W' else (['b', 'B'], ['w', 'W']) 87 | captures = [] 88 | start_pos, end_pos = None, None 89 | for index in range(1, len(old_fen)): 90 | if old_fen[index] in our_pieces and new_fen[index] == 'e': 91 | start_pos = index 92 | elif new_fen[index] in our_pieces and old_fen[index] == 'e': 93 | end_pos = index 94 | elif old_fen[index] in opponents_pieces and new_fen[index] == 'e': 95 | captures.append(index) 96 | hub_pos_move = None 97 | if start_pos and end_pos: 98 | hub_pos_move = game._game.make_len_2(start_pos) + game._game.make_len_2(end_pos) + game._game.sort_captures( 99 | captures) 100 | 101 | cbmove_output_2 = {} 102 | cbmove_output_2['jumps'] = cbmove.jumps 103 | cbmove_output_2['oldpiece'] = cbmove.oldpiece 104 | cbmove_output_2['newpiece'] = cbmove.newpiece 105 | cbmove_output_2['to'] = cbmove.to.x, cbmove.to.y 106 | cbmove.to = getattr(cbmove, 'from') 107 | cbmove_output_2['from'] = cbmove.to.x, cbmove.to.y 108 | cbmove_output_2['path'] = [(cbmove.path[i].x, cbmove.path[i].y) for i in range(12)] 109 | cbmove.path = getattr(cbmove, 'del') 110 | cbmove_output_2['del'] = [(cbmove.path[i].x, cbmove.path[i].y) for i in range(12)] 111 | cbmove_output_2['delpiece'] = [cbmove.delpiece[i] for i in range(12)] 112 | return hub_pos_move, output.value, cbmove_output_2, result 113 | -------------------------------------------------------------------------------- /draughts/engines/checkerboard_extra/get_checker_board.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import draughts 3 | from math import ceil 4 | 5 | 6 | # From CheckerBoard API: 7 | WHITE = 1 8 | BLACK = 2 9 | MAN = 4 10 | KING = 8 11 | FREE = 0 12 | 13 | 14 | def get_board(board: draughts.Board) -> ctypes.Array: 15 | """Get a CheckerBoard board (for use in CheckerBoard engines) from a Board() object.""" 16 | 17 | one_column = (ctypes.c_int * 8) 18 | checkerboard_board = (one_column * 8)() 19 | 20 | white_starts = board.variant not in ['english'] 21 | flip_column = board.variant not in ['english', 'italian'] 22 | 23 | for loc in range(1, board._game.board.position_count + 1): 24 | row = ceil(loc / board._game.board.width) - 1 # From get_row_from_position 25 | 26 | # Because in english black starts 27 | if not white_starts: 28 | row = (board._game.board.height - 1) - row 29 | 30 | column = (loc - 1) % board._game.board.width # From get_column 31 | 32 | # Because: 33 | # 1. In italian the bottom-left square isn't playable, so in CheckerBoard the board is flipped vertically. 34 | # 2. In most variants the bottom-left square for the starting side (usually white) is in column a, 35 | # while in english black starts, so the bottom-left square for the starting side (black) is in row h. 36 | if flip_column: 37 | column = board._game.board.width - 1 - column 38 | 39 | if row % 2 == 0: 40 | column = (column + 1) * 2 - 2 # To account for the always empty white squares 41 | else: 42 | column = (column + 1) * 2 - 1 # To account for the always empty white squares 43 | 44 | number = FREE 45 | if loc in board._game.board.searcher.filled_positions: 46 | piece = board._game.board.searcher.get_piece_by_position(loc) 47 | # In Checkerboard black starts first, so the colors are reversed 48 | if piece.player == draughts.WHITE and not piece.king: 49 | number = (WHITE if white_starts else BLACK) + MAN 50 | elif piece.player == draughts.BLACK and not piece.king: 51 | number = (BLACK if white_starts else WHITE) + MAN 52 | elif piece.player == draughts.WHITE and piece.king: 53 | number = (WHITE if white_starts else BLACK) + KING 54 | elif piece.player == draughts.BLACK and piece.king: 55 | number = (BLACK if white_starts else WHITE) + KING 56 | checkerboard_board[column][row] = number 57 | 58 | return checkerboard_board 59 | 60 | 61 | def from_board(checker_board: ctypes.Array, old_board: draughts.Board) -> str: 62 | """Get the Hub fen from a CheckerBoard board.""" 63 | 64 | # board_numbers = [[square for square in column] for column in checker_board] 65 | fen = 'B' if old_board.turn == draughts.WHITE else 'W' # switch turns 66 | 67 | white_starts = old_board.variant not in ['english'] 68 | flip_column = old_board.variant not in ['english', 'italian'] 69 | 70 | for loc in range(1, old_board._game.board.position_count + 1): 71 | row = ceil(loc / old_board._game.board.width) - 1 # From get_row_from_position 72 | if not white_starts: 73 | row = (old_board._game.board.height - 1) - row 74 | column = (loc - 1) % old_board._game.board.width # From get_column 75 | 76 | # Because: 77 | # 1. In italian the bottom-left square isn't playable, so in CheckerBoard the board is flipped vertically. 78 | # 2. In most variants the bottom-left square for the starting side (usually white) is in column a, 79 | # while in english black starts, so the bottom-left square for the starting side (black) is in row h. 80 | if flip_column: 81 | column = old_board._game.board.width - 1 - column 82 | 83 | if row % 2 == 0: 84 | column = (column + 1) * 2 - 2 # To account for the always empty white squares 85 | else: 86 | column = (column + 1) * 2 - 1 # To account for the always empty white squares 87 | 88 | # In Checkerboard black starts first, so the colors are reversed 89 | if checker_board[column][row] == FREE: 90 | fen += 'e' 91 | elif checker_board[column][row] == (BLACK if white_starts else WHITE) + MAN: 92 | fen += 'b' 93 | elif checker_board[column][row] == (WHITE if white_starts else BLACK) + MAN: 94 | fen += 'w' 95 | elif checker_board[column][row] == (BLACK if white_starts else WHITE) + KING: 96 | fen += 'B' 97 | elif checker_board[column][row] == (WHITE if white_starts else BLACK) + KING: 98 | fen += 'W' 99 | return fen 100 | -------------------------------------------------------------------------------- /draughts/engines/dxp.py: -------------------------------------------------------------------------------- 1 | import draughts.engines.dxp_communication.dxp_communication as dxp_communication 2 | import draughts 3 | import draughts.engine 4 | import subprocess 5 | import os 6 | import signal 7 | import threading 8 | import time 9 | import logging 10 | from typing import Optional, Dict, Union, List, Any, Tuple 11 | 12 | logger = logging.getLogger("pydraughts") 13 | 14 | 15 | class DXPEngine: 16 | def __init__(self, command: Union[List[str], str, None] = None, 17 | options: Optional[Dict[str, Union[str, int, bool]]] = None, initial_time: int = 0, 18 | cwd: Optional[str] = None, ENGINE: int = 5) -> None: 19 | if options is None: 20 | options = {} 21 | self.initial_time = initial_time 22 | self.max_moves = 0 23 | self.ip = '127.0.0.1' 24 | self.port = '27531' 25 | self.command = command 26 | self.engine_opened = True # Whether the engine is already open or pydraughts should open it 27 | self.wait_to_open_time = 10 28 | self.ENGINE = ENGINE 29 | self.info: Dict[str, Any] = {} 30 | self.id: Dict[str, str] = {} 31 | self.sender = dxp_communication.Sender() 32 | self.receiver = self.sender.receiver 33 | self.game_started = False 34 | self.exit = False 35 | 36 | self.configure(options) 37 | 38 | if not self.engine_opened: 39 | cwd = cwd or os.getcwd() 40 | cwd = os.path.realpath(os.path.expanduser(cwd)) 41 | if type(command) == str: 42 | command = [command] 43 | command = list(filter(None, command)) 44 | command[0] = os.path.realpath(os.path.expanduser(command[0])) 45 | command[0] = '"' + command[0] + '"' 46 | command = ' '.join(command) 47 | self.command = command 48 | self.p = self._open_process(command, cwd) 49 | self.engine_receive_thread = threading.Thread(target=self._recv) 50 | self.engine_receive_thread.start() 51 | 52 | self.start_time = time.perf_counter_ns() 53 | self.quit_time = 0 54 | 55 | def setoption(self, name: str, value: Union[str, int, bool]) -> None: 56 | """Set a DXP option.""" 57 | if name == 'engine-opened': 58 | self.engine_opened = bool(value) 59 | elif name == 'ip': 60 | self.ip = str(value) 61 | elif name == 'port': 62 | self.port = str(value) 63 | elif name == 'wait-to-open-time': 64 | self.wait_to_open_time = int(value) 65 | elif name == 'max-moves': 66 | self.max_moves = int(value) 67 | elif name == 'initial-time': 68 | self.initial_time = int(value) 69 | 70 | def configure(self, options: Dict[str, Union[str, int, bool]]) -> None: 71 | """Configure many options at once.""" 72 | for name, value in options.items(): 73 | self.setoption(name, value) 74 | 75 | def _open_process(self, command: str, cwd: Optional[str] = None, shell: bool = True, 76 | _popen_lock: Any = threading.Lock()) -> subprocess.Popen: 77 | """Open the engine process.""" 78 | kwargs = { 79 | "shell": shell, 80 | "stdout": subprocess.PIPE, 81 | "stderr": subprocess.STDOUT, 82 | "stdin": subprocess.PIPE, 83 | "bufsize": 1, # Line buffered 84 | "universal_newlines": True, 85 | } 86 | logger.debug(f'command: {command}, cwd: {cwd}') 87 | 88 | if cwd is not None: 89 | kwargs["cwd"] = cwd 90 | 91 | # Prevent signal propagation from parent process 92 | try: 93 | # Windows 94 | kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP 95 | except AttributeError: 96 | # Unix 97 | kwargs["preexec_fn"] = os.setpgrp 98 | 99 | with _popen_lock: # Work around Python 2 Popen race condition 100 | return subprocess.Popen(command, **kwargs) 101 | 102 | def kill_process(self) -> None: 103 | """Kill the engine process.""" 104 | if not self.engine_opened: 105 | wait_time = self.quit_time / 1e9 + 10 - time.perf_counter_ns() / 1e9 106 | logger.debug(f'wait time before killing: {wait_time}') 107 | if wait_time > 0: 108 | time.sleep(wait_time) 109 | self.exit = True 110 | try: 111 | # Windows 112 | logger.debug("Killing Windows.") 113 | self.p.send_signal(signal.CTRL_BREAK_EVENT) 114 | except AttributeError: 115 | # Unix 116 | logger.debug("Killing UNIX.") 117 | os.killpg(self.p.pid, signal.SIGTERM) 118 | time.sleep(7) 119 | os.killpg(self.p.pid, signal.SIGKILL) 120 | 121 | self.p.communicate() 122 | self.engine_receive_thread.join() 123 | 124 | def _connect(self) -> None: 125 | """Connect to the engine.""" 126 | if not self.engine_opened: 127 | wait_time = self.start_time / 1e9 + self.wait_to_open_time - time.perf_counter_ns() / 1e9 128 | logger.debug(f'wait time before connecting: {wait_time}') 129 | if wait_time > 0: 130 | time.sleep(wait_time) 131 | self.sender.connect(self.ip, int(self.port)) 132 | 133 | def _start(self, board: draughts.Board, game_time: int) -> None: 134 | """Start the game.""" 135 | self._connect() 136 | engine_color = 'B' if board.turn == draughts.WHITE else 'W' 137 | game_time = int(round(game_time // 60)) 138 | moves = self.max_moves 139 | self.sender.setup(board.initial_fen, board.variant) 140 | self.sender.gamereq(engine_color, game_time, moves) 141 | accepted = self._recv_accept() 142 | logger.debug(f'Aceepted: {accepted}') 143 | self.id["name"] = self.sender.current.engineName 144 | 145 | def _recv(self) -> None: 146 | """Receive a line from the engine, if the engine is opened by pydraughts.""" 147 | # The engine doesn't work otherwise. 148 | while True: 149 | try: 150 | line = self.p.stdout.readline() 151 | 152 | line = line.rstrip() 153 | 154 | if line: 155 | logger.debug(f"{self.ENGINE} %s >> %s {self.p.pid} {line}") 156 | except ValueError as err: 157 | if self.exit: 158 | break 159 | else: 160 | raise err 161 | 162 | def _recv_accept(self) -> bool: 163 | """Get if the game was accepted.""" 164 | while True: 165 | if self.receiver.accepted is not None: 166 | return self.receiver.accepted 167 | 168 | def _recv_move(self) -> Optional[draughts.Move]: 169 | """Receive the engine move.""" 170 | while True: 171 | if not self.receiver.listening: 172 | break 173 | if self.receiver.last_move_changed: 174 | logger.debug(f'new last move: {self.receiver.last_move.board_move}') 175 | return self.receiver.last_move 176 | return None 177 | 178 | def _recv_backreq(self) -> bool: 179 | """Get if the backreq was accepted.""" 180 | while True: 181 | if self.receiver.backreq_accepted is not None: 182 | return self.receiver.backreq_accepted 183 | 184 | def takeback(self, move: int, color: int) -> Tuple[bool, draughts.Board, Any]: 185 | """ 186 | Attempt to take back moves. 187 | Warning: If it is the engine's turn to play in the new position, it will attempt to move. 188 | """ 189 | self.receiver.backreq_accepted = None 190 | ply = (move - 1) * 2 + (0 if color == draughts.WHITE else 1) 191 | remove_count = 0 192 | best_move = None 193 | moves = list(map(lambda old_move: old_move.steps_move, self.sender.current.pos.move_stack)) 194 | logger.debug(f"Move stack before removing: {moves}") 195 | self.sender.backreq(move, color) 196 | backreq = self._recv_backreq() 197 | if backreq: 198 | while len(self.sender.current.pos.move_stack) > ply: 199 | remove_count += 1 200 | self.sender.current.pos.pop() 201 | moves = list(map(lambda old_move: old_move.steps_move, self.sender.current.pos.move_stack)) 202 | logger.debug(f"Move stack after removing {remove_count} moves: {moves}") 203 | self.receiver.takeback_in_progress = False 204 | new_board = self.sender.current.pos.copy() 205 | if self.sender.current.engine_color == self.sender.current.get_color(): # It is the engine's turn to play. 206 | best_move = self._recv_move() 207 | else: 208 | new_board = self.sender.current.pos.copy() 209 | return backreq, new_board, draughts.engine.PlayResult(best_move, None, {}) 210 | 211 | def play(self, board: draughts.Board) -> Any: 212 | """Engine search.""" 213 | if not self.game_started: 214 | self._start(board, self.initial_time) 215 | self.game_started = True 216 | if board.move_stack: 217 | self.sender.send_move(board.move_stack[-1]) 218 | best_move = self._recv_move() 219 | return draughts.engine.PlayResult(best_move, None, {}) 220 | 221 | def quit(self) -> None: 222 | """Quit the engine.""" 223 | self.sender.gameend() 224 | self.sender.disconnect() 225 | self.quit_time = time.perf_counter_ns() 226 | -------------------------------------------------------------------------------- /draughts/engines/dxp_communication/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AttackingOrDefending/pydraughts/3277325ccc17cca75e46da454dc7fb8c0c32150b/draughts/engines/dxp_communication/__init__.py -------------------------------------------------------------------------------- /draughts/engines/dxp_communication/dxp_classes.py: -------------------------------------------------------------------------------- 1 | # This file is an adaptation of DXC100_draughts_client (https://github.com/akalverboer/DXC100_draughts_client) by akalverboer. 2 | 3 | from __future__ import annotations 4 | import socket 5 | import logging 6 | import draughts 7 | import time 8 | from typing import Dict, Optional, List, Union 9 | 10 | logger = logging.getLogger("pydraughts") 11 | 12 | DXP_WHITE = 0 13 | DXP_BLACK = 1 14 | 15 | 16 | class GameStatus: 17 | def __init__(self, fen: str = 'startpos', engine_color: int = DXP_WHITE, started: bool = False, 18 | variant: str = 'standard') -> None: 19 | self.fen = fen 20 | self.engine_color = engine_color 21 | self.started = started 22 | self.variant = variant 23 | self.pos = draughts.Board(fen=fen, variant=variant) 24 | self.color = self.get_color() 25 | self.engineName = '' 26 | self.result = None 27 | 28 | def get_color(self) -> int: 29 | """Get the color of the playing side.""" 30 | return DXP_WHITE if self.pos.turn == draughts.WHITE else DXP_BLACK 31 | 32 | 33 | class MySocket: 34 | def __init__(self) -> None: 35 | self.sock: Optional[socket.socket] = None 36 | self.closed = False 37 | 38 | def open(self) -> MySocket: 39 | """Open the socket.""" 40 | try: 41 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 42 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 43 | self.closed = False 44 | except Exception: 45 | self.sock = None 46 | raise Exception("socket exception: failed to open") 47 | return self 48 | 49 | def connect(self, host: str, port: int) -> MySocket: 50 | """Connect to the engine.""" 51 | self.sock.settimeout(10) # timeout for connection 52 | try: 53 | self.sock.connect((host, port)) 54 | except socket.error as msg: 55 | self.sock = None 56 | raise Exception(f"connection exception: failed to connect ({msg}).") 57 | if self.sock is not None: 58 | self.sock.settimeout(None) # default 59 | return self 60 | 61 | def send(self, msg: str) -> None: 62 | """Send a message to the engine.""" 63 | try: 64 | logger.debug(f"socket send: {msg}") 65 | self.sock.send(bytes(msg, 'utf-8') + b"\0") 66 | except Exception: 67 | raise Exception("send exception: no connection") 68 | return None 69 | 70 | def receive(self) -> str: 71 | """Receive a message from the engine.""" 72 | msg = "" 73 | while True: 74 | # Collect message chunks until null character found 75 | try: 76 | chunk = self.sock.recv(1024) 77 | except Exception: 78 | raise Exception("receive exception: no connection") 79 | 80 | if chunk == "": 81 | raise Exception("receive exception: socket connection broken") 82 | msg += chunk.decode() 83 | if msg.find("\0") > -1: 84 | break 85 | if len(msg) > 128: 86 | break # too long, no null char 87 | 88 | logger.debug(f"socket receive: {msg}") 89 | msg = msg.replace("\0", "") # remove all null chars 90 | 91 | # Use strip to remove all whitespace at the start and end. 92 | # Including spaces, tabs, newlines and carriage returns. 93 | msg = msg.strip() 94 | return msg 95 | 96 | def close(self) -> None: 97 | if self.sock and not self.closed: 98 | self.closed = True 99 | self.sock.shutdown(socket.SHUT_RDWR) 100 | time.sleep(7) 101 | self.sock.close() 102 | self.sock = None 103 | 104 | def __del__(self) -> None: 105 | self.close() 106 | 107 | 108 | class DamExchange: 109 | def parse(self, msg: str) -> Dict[str, Union[str, List[str]]]: 110 | """Parse an incoming DXP message.""" 111 | # Parse incoming DXP message. Returns relevant items depending on mtype. 112 | result: Dict[str, Union[str, List[str]]] = {} 113 | mtype = msg[0:1] 114 | if mtype == "C": # CHAT 115 | result['type'] = "C" 116 | result['text'] = msg[1:127] 117 | elif mtype == "R": # GAMEREQ (only received by FOLLOWER) 118 | result['type'] = "R" 119 | result['name'] = msg[3:35].strip() # initiator 120 | result['fColor'] = msg[35:36] # color of follower 121 | result['gameTime'] = msg[36:39] 122 | result['numMoves'] = msg[39:42] 123 | result['posInd'] = msg[42:43] 124 | if result['posInd'] != "A": 125 | result['mColor'] = msg[43:44] # color to move for position 126 | result['pos'] = msg[44:94] 127 | elif mtype == "A": # GAMEACC 128 | result['type'] = "A" 129 | result['engineName'] = msg[1:33].strip() # follower name 130 | result['accCode'] = msg[33:34] 131 | elif mtype == "M": # MOVE 132 | result['type'] = "M" 133 | result['time'] = msg[1:5] 134 | result['from'] = msg[5:7] 135 | result['to'] = msg[7:9] 136 | result['nCaptured'] = msg[9:11] 137 | result['captures'] = [] 138 | for i in range(int(result['nCaptured'])): 139 | s = i * 2 140 | result['captures'].append(msg[11 + s:13 + s]) 141 | elif mtype == "E": # GAMEEND 142 | result['type'] = "E" 143 | result['reason'] = msg[1:2] 144 | result['stop'] = msg[2:3] 145 | elif mtype == "B": # BACKREQ 146 | result['type'] = "B" 147 | result['moveId'] = msg[1:4] 148 | result['mColor'] = msg[4:5] 149 | elif mtype == "K": # BACKACC 150 | result['type'] = "K" 151 | result['accCode'] = msg[1:2] 152 | else: 153 | result['type'] = "?" 154 | return result 155 | 156 | def msg_chat(self, msg: str) -> str: 157 | """Generate a CHAT message.""" 158 | # Generate CHAT message. Example: CWhat do you think about move 35? 159 | msg = "C" + msg 160 | return msg 161 | 162 | def msg_gamereq(self, my_color: int, game_time: int, num_moves: int, pos: Optional[draughts.Board] = None, 163 | color_to_move: Optional[int] = None) -> str: 164 | """Generate a GAMEREQ message.""" 165 | # Generate GAMEREQ message. Example: R01Tornado voor Windows 4.0 W060065A 166 | gamereq = [] 167 | gamereq.append("R") # header 168 | gamereq.append("01") # version 169 | 170 | gamereq.append("DXP Client".ljust(32)[:32]) # iName: fixed length padding spaces 171 | gamereq.append('Z' if my_color == DXP_WHITE else 'W') # fColor: color of follower (server) 172 | gamereq.append(str(game_time).zfill(3)) # game_time: time limit of game (ex: 090) 173 | gamereq.append(str(num_moves).zfill(3)) # num_moves: number of moves of time limit (ex: 050) 174 | if pos is None or color_to_move is None: 175 | gamereq.append("A") # posInd == A: use starting position 176 | else: 177 | gamereq.append("B") # posInd == B: use parameters pos and color_to_move 178 | gamereq.append("W" if color_to_move == DXP_WHITE else "Z") # mColor 179 | gamereq.append(pos._game.get_dxp_fen()) # board 180 | 181 | msg = "" 182 | for item in gamereq: 183 | msg = msg + item 184 | return msg 185 | 186 | def msg_move(self, steps: List[int], captures: List[int], time_spent: int) -> str: 187 | """Generate a MOVE message.""" 188 | # Generate MOVE message. Example: M001205250422122320 189 | # Parm rmove is a "two-color" move 190 | move = [] 191 | move.append("M") # header 192 | move.append(str(time_spent % 10000).zfill(4)) # mTime: 0000 .. 9999 193 | move.append(str(steps[0] % 100).zfill(2)) # mFrom 194 | move.append(str(steps[-1] % 100).zfill(2)) # mTo 195 | move.append(str(len(captures) % 100).zfill(2)) # mNumCaptured: number of takes (captures) 196 | for k in captures: 197 | move.append(str(k % 100).zfill(2)) # mCaptures 198 | 199 | msg = "" 200 | for item in move: 201 | msg = msg + item 202 | return msg 203 | 204 | def msg_gameend(self, reason: str) -> str: 205 | """Generate a GAMEEND message.""" 206 | # Generate GAMEEND message. Example: E00 207 | gameend = [] 208 | gameend.append("E") # header 209 | gameend.append(str(reason)[0]) # reason: 0 > unknown 1 > I lose 2 > draw 3 > I win 210 | gameend.append("1") # stop code: 0 > next game preferred 1: > no next game 211 | msg = "" 212 | for item in gameend: 213 | msg = msg + item 214 | return msg 215 | 216 | def msg_backreq(self, moveId: int, color_to_move: int) -> str: 217 | """Generate a BACKREQ message.""" 218 | # Generate BACKREQ message. Example: B005Z 219 | backreq = [] 220 | backreq.append("B") 221 | backreq.append(str(moveId % 1000).zfill(3)) # moveId 222 | backreq.append("W" if color_to_move == DXP_WHITE else "Z") # mColor 223 | msg = "" 224 | for item in backreq: 225 | msg = msg + item 226 | return msg 227 | 228 | def msg_backacc(self, accCode: str) -> str: 229 | """Generate the response to a BACKREQ request.""" 230 | # Generate BACKREQ message. Example: K1 231 | backreq = [] 232 | backreq.append("K") 233 | backreq.append(str(accCode[0])) # accCode 234 | msg = "" 235 | for item in backreq: 236 | msg = msg + item 237 | return msg 238 | -------------------------------------------------------------------------------- /draughts/engines/dxp_communication/dxp_communication.py: -------------------------------------------------------------------------------- 1 | # This file is an adaptation of DXC100_draughts_client (https://github.com/akalverboer/DXC100_draughts_client) by akalverboer. 2 | 3 | from __future__ import annotations 4 | import threading 5 | import logging 6 | from draughts.engines.dxp_communication.dxp_classes import DamExchange, MySocket, GameStatus, DXP_WHITE, DXP_BLACK 7 | from draughts import Move, WHITE 8 | from typing import Optional 9 | 10 | logger = logging.getLogger("pydraughts") 11 | 12 | 13 | class Receiver: 14 | def __init__(self, sender: Sender) -> None: 15 | self.sender = sender 16 | self.accepted: Optional[bool] = None 17 | self.gameend_sent = False 18 | self.last_move: Optional[Move] = None 19 | self.last_move_changed = False 20 | self.listening = False 21 | self.receive_thread_started = False 22 | self.backreq_accepted: Optional[bool] = None 23 | self.takeback_in_progress = False 24 | self.receive_thread = threading.Thread(target=self.receive) 25 | 26 | def start(self) -> None: 27 | self.receive_thread_started = True 28 | self.receive_thread.start() 29 | 30 | def close(self) -> None: 31 | if self.receive_thread_started: 32 | self.receive_thread.join() 33 | 34 | def receive(self) -> None: 35 | logger.debug("DXP Client starts listening") 36 | self.listening = True 37 | while True: 38 | try: 39 | message = self.sender.socket.receive() # wait for message 40 | except Exception as err: 41 | logger.debug(f"Error {err}") 42 | self.sender.socket.close() 43 | break 44 | 45 | message = message[0:127] # DXP max length 46 | dxp_data = self.sender.dxp.parse(message) 47 | if dxp_data["type"] == "C": 48 | logger.debug(f"rcv CHAT: {message}") 49 | logger.debug(f"\nChat message: {dxp_data['text']}") 50 | 51 | elif dxp_data["type"] == "A": 52 | logger.debug(f"rcv GAMEACC: {message}") 53 | if dxp_data["accCode"] == "0": 54 | self.sender.current.engine_color = self.sender.current.engine_color # as requested 55 | self.sender.current.engineName = dxp_data["engineName"] 56 | logger.debug(f"\nGame request accepted by {dxp_data['engineName']}") 57 | self.accepted = True 58 | self.gameend_sent = False 59 | self.sender.current.started = True 60 | else: 61 | logger.debug(f"\nGame request NOT accepted by {dxp_data['engineName']} Reason: {dxp_data['accCode']}") 62 | self.accepted = False 63 | self.sender.current.started = False 64 | 65 | elif dxp_data["type"] == "E": 66 | logger.debug(f"rcv GAMEEND: {message}") 67 | logger.debug(f"\nRequest end of game accepted. Reason: {dxp_data['reason']} Stop: {dxp_data['stop']}") 68 | # Confirm game end by sending message back (if not sent by me) 69 | if self.sender.current.started and not self.gameend_sent: 70 | self.sender.current.result = dxp_data["reason"] 71 | msg = self.sender.dxp.msg_gameend(str(dxp_data["reason"])) # `str` is there only for mypy. 72 | self.sender.socket.send(msg) 73 | logger.debug(f"snd GAMEEND: {msg}") 74 | self.gameend_sent = True 75 | self.sender.current.started = False 76 | 77 | elif dxp_data["type"] == "M": 78 | logger.debug(f"rcv MOVE: {message}") 79 | steps = [dxp_data['from'], dxp_data['to']] 80 | nsteps = list(map(int, steps)) 81 | ntakes_int = list(map(int, dxp_data['captures'])) 82 | ntakes = list(map(lambda pos: str(pos).zfill(2), sorted(ntakes_int))) 83 | logger.debug(f"FEN: {self.sender.current.pos.fen}, Steps: {nsteps}, Takes: {ntakes}") 84 | correct_move = None 85 | while True: 86 | if not self.takeback_in_progress: 87 | break 88 | for move in self.sender.current.pos.legal_moves(): 89 | if move.hub_position_move == f"{str(nsteps[0]).zfill(2)}{str(nsteps[-1]).zfill(2)}{''.join(ntakes)}": 90 | correct_move = move 91 | 92 | if correct_move is not None: 93 | self.sender.current.pos.push(correct_move) 94 | self.last_move = correct_move 95 | logger.debug(f"Move received: {correct_move.steps_move}") 96 | self.last_move_changed = True 97 | else: 98 | move_history = list(map(lambda old_move: old_move.steps_move, self.sender.current.pos.move_stack)) 99 | logger.debug(f"Error: received move is illegal [{message}]\nMove history: {move_history}") 100 | 101 | elif dxp_data["type"] == "B": 102 | # For the time being do not confirm request from server: send message back. 103 | logger.debug(f"rcv BACKREQ: {message}") 104 | acc_code = "1" # 0: BACK YES; 1: BACK NO; 2: CONTINUE 105 | msg = self.sender.dxp.msg_backacc(acc_code) 106 | self.sender.socket.send(msg) 107 | logger.debug(f"snd BACKACC: {msg}") 108 | 109 | elif dxp_data["type"] == "K": 110 | # Answer to my request to move back 111 | logger.debug(f"rcv BACKACC: {message}") 112 | logger.debug(f"rcv BACKACC: {message}") # TEST 113 | acc_code = dxp_data['accCode'] 114 | if acc_code == "0": 115 | # Actions to go back in history as specified in my request 116 | self.backreq_accepted = True 117 | elif acc_code == "1": 118 | logger.debug("Engine doesn't support going back.") 119 | self.backreq_accepted = False 120 | else: 121 | logger.debug("Engine wants to continue the game.") 122 | self.backreq_accepted = False 123 | 124 | else: 125 | logger.debug(f"rcv UNKNOWN: {message}") 126 | logger.debug(f"\nrcv Unknown message: {message}") 127 | 128 | self.listening = False 129 | 130 | 131 | class Sender: 132 | def __init__(self) -> None: 133 | self.dxp = DamExchange() 134 | self.socket = MySocket() 135 | self.current = GameStatus("W") 136 | self.receiver = Receiver(self) 137 | 138 | def setup(self, fen: str, variant: str) -> None: 139 | logger.debug(f"FEN: {fen.strip()}, Variant: {variant}") 140 | engine_color = DXP_BLACK if fen[0] == 'B' else DXP_WHITE 141 | self.current = GameStatus(fen=fen, engine_color=engine_color, variant=variant) 142 | 143 | def send_move(self, move: Move) -> None: 144 | logger.debug(f"FEN: {self.current.pos.fen}, Steps: {move.steps_move}, Captures: {move.captures}") 145 | time_spent = 0 146 | self.receiver.last_move_changed = False 147 | self.current.pos.push(move) 148 | msg = self.dxp.msg_move(move.steps_move, move.captures, time_spent) 149 | try: 150 | self.socket.send(msg) 151 | logger.debug(f"snd MOVE: {msg}") 152 | except Exception as err: 153 | logger.debug(f"Error sending move: {err}") 154 | return 155 | 156 | def connect(self, host: str, port: int) -> None: 157 | logger.debug(f"Host: {host}, Port: {port}") 158 | try: 159 | self.socket.open() 160 | self.socket.connect(host, int(port)) # with timeout 161 | logger.debug(f"Successfully connected to remote host {host}, port {port}") 162 | except Exception as err: 163 | self.socket.sock = None 164 | logger.debug(f"Error trying to connect: {err}") 165 | return 166 | self.receiver.start() 167 | 168 | def disconnect(self) -> None: 169 | logger.debug("Attempting to disconnect.") 170 | try: 171 | self.socket.close() 172 | logger.debug("Successfully closed socket.") 173 | except Exception as err: 174 | self.socket.sock = None 175 | logger.debug(f"Error trying to close socket: {err}") 176 | self.receiver.close() 177 | 178 | def chat(self, text: str) -> None: 179 | text = text.strip() # trim whitespace 180 | logger.debug(f"Text: {text}") 181 | msg = self.dxp.msg_chat(text) 182 | try: 183 | self.socket.send(msg) 184 | logger.debug(f"snd CHAT: {msg}") 185 | except Exception as err: 186 | logger.debug(f"Error sending chat message: {err}") 187 | 188 | def gamereq(self, engine_color: str, time: int, max_moves: int) -> None: 189 | logger.debug(f"Engine color: {engine_color}, Time: {time}, Max moves: {max_moves}") 190 | dxp_color = DXP_WHITE if engine_color.upper().startswith('W') else DXP_BLACK 191 | msg = self.dxp.msg_gamereq(dxp_color, time, max_moves, self.current.pos, self.current.get_color()) 192 | try: 193 | self.socket.send(msg) 194 | logger.debug(f"snd GAMEREQ: {msg}") 195 | except Exception as err: 196 | logger.debug(f"Error sending game request: {err}") 197 | 198 | def gameend(self) -> None: 199 | logger.debug("Attempting to send a gameend.") 200 | reason = "0" 201 | msg = self.dxp.msg_gameend(reason) 202 | self.receiver.gameend_sent = True 203 | try: 204 | self.socket.send(msg) 205 | logger.debug(f"snd GAMEEND: {msg}") 206 | except Exception as err: 207 | logger.debug(f"Error sending gameend message: {err}") 208 | 209 | def backreq(self, move: int, color: int) -> None: 210 | logger.debug(f"Request to return to {move}th move with {'WHITE' if color == WHITE else 'BLACK'} to move.") 211 | msg = self.dxp.msg_backreq(move, DXP_WHITE if color == WHITE else DXP_BLACK) 212 | self.receiver.last_move_changed = False 213 | self.receiver.takeback_in_progress = True 214 | try: 215 | self.socket.send(msg) 216 | logger.debug(f"snd BACKREQ: {msg}") 217 | except Exception as err: 218 | logger.debug(f"Error sending backreq request: {err}") 219 | -------------------------------------------------------------------------------- /draughts/engines/hub.py: -------------------------------------------------------------------------------- 1 | # This file is an adaptation of fishnet (https://github.com/lichess-org/fishnet/tree/ebd2a5e16d37135509cbfbff9998e0b798866ef5). 2 | 3 | import subprocess 4 | import os 5 | import signal 6 | import logging 7 | import threading 8 | import draughts 9 | import draughts.engine 10 | import re 11 | import math 12 | from typing import Union, Optional, List, Tuple, Any, Dict, Set 13 | 14 | logger = logging.getLogger("pydraughts") 15 | 16 | 17 | class HubEngine: 18 | def __init__(self, command: Union[List[str], str], cwd: Optional[str] = None, ENGINE: int = 5) -> None: 19 | self.ENGINE = ENGINE 20 | self.info: Dict[str, Any] = {} 21 | self.id: Dict[str, str] = {} 22 | self.options: Set[str] = set() 23 | self.variants: Set[str] = set() 24 | cwd = cwd or os.getcwd() 25 | cwd = os.path.realpath(os.path.expanduser(cwd)) 26 | if type(command) == str: 27 | command = [command] 28 | command = list(filter(bool, command)) 29 | command[0] = os.path.realpath(os.path.expanduser(command[0])) 30 | command[0] = '"' + command[0] + '"' 31 | command = ' '.join(command) 32 | self.p = self._open_process(command, cwd) 33 | self._last_sent = "" 34 | self.hub() 35 | 36 | def _open_process(self, command: str, cwd: Optional[str] = None, shell: Optional[bool] = True, 37 | _popen_lock: Any = threading.Lock()) -> subprocess.Popen: 38 | """Open the engine process.""" 39 | kwargs: Dict[str, Any] = { 40 | "shell": shell, 41 | "stdout": subprocess.PIPE, 42 | "stderr": subprocess.STDOUT, 43 | "stdin": subprocess.PIPE, 44 | "bufsize": 1, # Line buffered 45 | "universal_newlines": True, 46 | } 47 | 48 | if cwd is not None: 49 | kwargs["cwd"] = cwd 50 | 51 | # Prevent signal propagation from parent process 52 | try: 53 | # Windows 54 | kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP 55 | except AttributeError: 56 | # Unix 57 | kwargs["preexec_fn"] = os.setpgrp 58 | 59 | with _popen_lock: # Work around Python 2 Popen race condition 60 | return subprocess.Popen(command, **kwargs) 61 | 62 | def kill_process(self) -> None: 63 | """Kill the engine process.""" 64 | try: 65 | # Windows 66 | self.p.send_signal(signal.CTRL_BREAK_EVENT) 67 | except AttributeError: 68 | # Unix 69 | os.killpg(self.p.pid, signal.SIGKILL) 70 | 71 | self.p.communicate() 72 | 73 | def send(self, line: str) -> None: 74 | """Send a command to the engine.""" 75 | if line == "ponder-hit": 76 | while self._last_sent != "go ponder": 77 | pass 78 | logger.debug(f"{self.ENGINE} %s << %s {self.p.pid} {line}") 79 | 80 | self.p.stdin.write(line + "\n") 81 | self.p.stdin.flush() 82 | self._last_sent = line 83 | 84 | def recv(self) -> str: 85 | """Receive a line from the engine.""" 86 | while True: 87 | line = self.p.stdout.readline() 88 | if line == "": 89 | raise EOFError() 90 | 91 | line = line.rstrip() 92 | 93 | logger.debug(f"{self.ENGINE} %s >> %s {self.p.pid} {line}") 94 | 95 | if line: 96 | return line 97 | 98 | def recv_hub(self) -> List[str]: 99 | """Receive a line from the engine and split at the first space.""" 100 | command_and_args = self.recv().split(None, 1) 101 | if len(command_and_args) == 1: 102 | return [command_and_args[0], ""] 103 | elif len(command_and_args) == 2: 104 | return command_and_args 105 | return [] 106 | 107 | def hub(self) -> Tuple[Dict[str, str], Set[str], Set[str]]: 108 | """Send the hub command to an engine.""" 109 | self.send("hub") 110 | 111 | engine_info: Dict[str, str] = {} 112 | options: Set[str] = set() 113 | variants: Set[str] = set() 114 | 115 | while True: 116 | command, arg = self.recv_hub() 117 | 118 | if command == "wait": 119 | return engine_info, options, variants 120 | elif command == "id": 121 | args = re.split(r' +(?![^"]*"(?:(?:[^"]*"){2})*[^"]*$)', arg) 122 | split_args = list(map(lambda item: item.split("="), args)) 123 | for key, value in split_args: 124 | engine_info[key] = value 125 | elif command == "param": 126 | args = re.split(r' +(?![^"]*"(?:(?:[^"]*"){2})*[^"]*$)', arg) 127 | split_args = list(map(lambda item: item.split("="), args)) 128 | is_variant = False 129 | for key, value in split_args: 130 | if key == "name": 131 | options.add(value) 132 | if value == "variant": 133 | is_variant = True 134 | if key == "values" and is_variant: 135 | if value.startswith('"') and value.endswith('"'): 136 | value = value[1:-1] 137 | for variant in value.split(): 138 | variants.add(variant) 139 | else: 140 | logger.warning("Unexpected engine response to hub: %s %s", command, arg) 141 | self.options = options 142 | self.variants = variants 143 | self.id = engine_info 144 | 145 | def init(self) -> None: 146 | """Initialize the engine.""" 147 | self.send("init") 148 | while True: 149 | command, arg = self.recv_hub() 150 | if command == "ready": 151 | break 152 | elif command == "init": 153 | pass 154 | else: 155 | logger.warning("Unexpected engine response to init: %s %s", command, arg) 156 | 157 | def ping(self) -> None: 158 | """Send the engine ping. They should reply with pong.""" 159 | self.send("ping") 160 | while True: 161 | command, arg = self.recv_hub() 162 | if command == "pong": 163 | break 164 | else: 165 | logger.warning("Unexpected engine response to ping: %s %s", command, arg) 166 | 167 | def setoption(self, name: str, value: Union[str, bool, None]) -> None: 168 | """Set an engine option.""" 169 | if value is True: 170 | value = "true" 171 | elif value is False: 172 | value = "false" 173 | elif value is None: 174 | value = "none" 175 | 176 | if name == 'variant' and self.variants or name != 'variant': 177 | self.send("set-param name=%s value=%s" % (name, value)) 178 | 179 | def configure(self, options: Dict[str, Union[str, bool, None]]) -> None: 180 | """Configure many options at once.""" 181 | for name, value in options.items(): 182 | self.setoption(name, value) 183 | 184 | def go(self, fen: str, moves: Optional[str] = None, my_time: Union[int, float, None] = None, 185 | inc: Union[int, float, None] = None, moves_left: Optional[int] = None, 186 | movetime: Union[int, float, None] = None, depth: Optional[int] = None, nodes: Optional[int] = None, 187 | ponder: Optional[bool] = False) -> Tuple[str, Optional[str]]: 188 | """Send the engine a go command.""" 189 | if moves: 190 | self.send(f'pos pos={fen} moves="{moves}"') 191 | else: 192 | self.send(f'pos pos={fen}') 193 | 194 | if my_time is not None and inc and moves_left: 195 | my_time -= inc # Hub engines first add the increment 196 | self.send(f'level moves={moves_left} time={my_time} inc={inc}') 197 | elif my_time is not None and inc: 198 | my_time -= inc # Hub engines first add the increment 199 | self.send(f'level time={my_time} inc={inc}') 200 | elif my_time is not None and moves_left: 201 | self.send(f'level moves={moves_left} time={my_time}') 202 | elif my_time is not None: 203 | self.send(f'level time={my_time}') 204 | elif movetime is not None: 205 | self.send(f'level move-time={movetime}') 206 | elif depth is not None: 207 | self.send(f'level depth={depth}') 208 | elif nodes is not None: 209 | self.send(f'level nodes={nodes}') 210 | 211 | if ponder: 212 | self.send('go ponder') 213 | else: 214 | self.send('go think') 215 | 216 | self.info = {} 217 | while True: 218 | command, arg = self.recv_hub() 219 | if command == "done": 220 | args = arg.split() 221 | pondermove = None 222 | split_args = list(map(lambda item: item.split("="), args)) 223 | bestmove = split_args[0][1] 224 | if len(split_args) == 2: 225 | pondermove = split_args[1][1] 226 | return bestmove, pondermove 227 | elif command == "info": 228 | args = re.split(r' +(?![^"]*"(?:(?:[^"]*"){2})*[^"]*$)', arg) 229 | split_args = list(map(lambda item: item.split("="), args)) 230 | value: Any 231 | for key, value in split_args: 232 | if key in ["depth", "nodes"]: 233 | value = int(value) 234 | elif key in ["mean-depth", "time", "nps"]: 235 | value = float(value) 236 | elif key == "score": 237 | score = int(float(value) * 100) 238 | mate = None 239 | if score > 9000: 240 | mate = 10000 - score 241 | elif score < -9000: 242 | mate = -10000 - score 243 | if mate: 244 | value = {"win": math.ceil(mate / 2)} 245 | else: 246 | value = {"cp": score} 247 | self.info[key] = value 248 | else: 249 | logger.warning("Unexpected engine response to go: %s %s", command, arg) 250 | 251 | def stop(self) -> None: 252 | """Stop the engine from searching.""" 253 | self.send("stop") 254 | 255 | def ponderhit(self) -> None: 256 | """Send ponder-hit to the engine.""" 257 | self.send("ponder-hit") 258 | 259 | def quit(self) -> None: 260 | """Quit the engine.""" 261 | self.send("quit") 262 | 263 | def play(self, board: draughts.Board, time_limit: Any, ponder: bool) -> Any: 264 | """Engine search.""" 265 | time = time_limit.time 266 | inc = time_limit.inc 267 | depth = time_limit.depth 268 | nodes = time_limit.nodes 269 | movetime = time_limit.movetime 270 | hub_moves = list(map(lambda move: move.hub_move, board.move_stack)) 271 | bestmove, pondermove = self.go(board._game.initial_hub_fen, moves=' '.join(hub_moves), my_time=time, inc=inc, 272 | depth=depth, nodes=nodes, movetime=movetime, ponder=ponder) 273 | 274 | ponder_move = None 275 | ponder_board = board.copy() 276 | best_move = draughts.Move(ponder_board, hub_move=bestmove) 277 | if pondermove: 278 | ponder_board.push(best_move) 279 | ponder_move = draughts.Move(ponder_board, hub_move=pondermove) 280 | 281 | return draughts.engine.PlayResult(best_move, ponder_move, self.info) 282 | -------------------------------------------------------------------------------- /draughts/svg.py: -------------------------------------------------------------------------------- 1 | import math 2 | from draughts import Board 3 | 4 | 5 | def create_svg(board: Board) -> str: 6 | """Create an SVG of a board.""" 7 | # Base square size 8 | square_size = 40 9 | margin = 16 # Fixed margin size for coordinates 10 | 11 | # Calculate SVG dimensions based on board size 12 | str_representation = list(map(lambda row_str: row_str.split("|"), filter(lambda row_str: "|" in row_str, str(board).split("\n")))) 13 | width = len(str_representation[0]) 14 | height = len(str_representation) 15 | svg_width = (square_size * width) + (2 * margin) 16 | svg_height = (square_size * height) + (2 * margin) 17 | 18 | # Background color for coordinates 19 | svg = [f''' 20 | '''] 21 | 22 | # Add coordinates in white 23 | for i in range(width): 24 | # Letters along bottom 25 | svg.append(f'{chr(97 + i)}') 29 | 30 | for i in range(height): 31 | # Numbers along left side 32 | svg.append(f'{height - i}') 36 | 37 | # Draw board 38 | for row in range(height): 39 | for col in range(width): 40 | x = margin + col * square_size 41 | y = margin + row * square_size 42 | color = "#E8D0AA" if (row + col) % 2 == 0 else "#B87C4C" 43 | svg.append(f'') 45 | 46 | # Draw pieces 47 | for row, row_str in enumerate(str_representation): 48 | for col, piece in enumerate(row_str): 49 | piece = piece.strip() 50 | if not piece: 51 | continue 52 | 53 | # Center of square 54 | cx = margin + col * square_size + square_size // 2 55 | cy = margin + row * square_size + square_size // 2 56 | 57 | piece_radius = square_size * 0.4 58 | piece_color = "#000000" if piece.lower() == 'b' else "#FFFFFF" 59 | stroke_color = "#FFFFFF" if piece.lower() == 'b' else "#000000" 60 | 61 | # Draw main piece 62 | svg.append(f'') 64 | 65 | # Enhanced crown for kings 66 | if piece.isupper(): 67 | gradient_id = f"crown_gradient_{cx}_{cy}" 68 | svg.append(f''' 69 | 70 | 71 | 72 | 73 | 74 | ''') 75 | 76 | # Draw 5-pointed star 77 | num_points = 5 78 | outer_radius = piece_radius * 0.5 79 | inner_radius = outer_radius * 0.382 80 | points = [] 81 | 82 | for i in range(num_points * 2): 83 | angle = (i * math.pi / num_points) - (math.pi / 2) 84 | radius = outer_radius if i % 2 == 0 else inner_radius 85 | x = cx + radius * math.cos(angle) 86 | y = cy + radius * math.sin(angle) 87 | points.append(f"{x},{y}") 88 | 89 | svg.append(f'') 92 | 93 | svg.append('') 94 | return '\n'.join(svg) 95 | -------------------------------------------------------------------------------- /draughts/tournament.py: -------------------------------------------------------------------------------- 1 | from draughts.engine import HubEngine, DXPEngine, CheckerBoardEngine, Limit 2 | from draughts import Board, WHITE, BLACK 3 | from draughts.PDN import PDNWriter 4 | from typing import List, Tuple, Dict, Any, Union, Optional 5 | import datetime 6 | import itertools 7 | import time 8 | import logging 9 | 10 | logger = logging.getLogger("pydraughts") 11 | 12 | 13 | class RoundRobin: 14 | def __init__(self, filename: str, players: List[Tuple[Union[str, List[str]], str, Dict[str, Any], Optional[str]]], 15 | start_time: Union[int, float], increment: Union[int, float] = 0, variant: str = "standard", 16 | games_per_pair: int = 2, starting_fen: str = "startpos", max_moves: int = 300) -> None: 17 | self.filename = filename 18 | self.players = players 19 | self.start_time = start_time 20 | self.increment = increment 21 | self.variant = variant 22 | self.games_per_pair = games_per_pair 23 | self.starting_fen = starting_fen 24 | self.max_moves = max_moves 25 | self.player_count = len(self.players) 26 | self.int_players = list(range(self.player_count)) 27 | self.results = [[0, 0, 0] for _ in range(self.player_count)] 28 | self.scores = [0] * self.player_count 29 | self.complete_results: List[List[Tuple[Tuple[int, int, int], Tuple[int, int, int]]]] = [] 30 | self.pairs: List[Tuple[int, int]] = list(itertools.combinations(self.int_players, 2)) 31 | logger.debug(f"There are {len(self.pairs)} possible pairs.") 32 | self.complete_pairs: List[List[Tuple[int, int]]] = [] 33 | self.get_complete_pairs() 34 | logger.debug(f"There will be {len(self.complete_pairs)} games.") 35 | date = datetime.datetime.now() 36 | year = str(date.year).zfill(4) 37 | month = str(date.month).zfill(2) 38 | day = str(date.day).zfill(2) 39 | self.tags = {"Event": "Tournament", "Site": "pydraughts", "EventDate": f"{year}.{month}.{day}", 40 | "ResultType": "International", "WhiteType": "program", "BlackType": "program", "GameType": "20", 41 | "TimeControl": f"{self.start_time}+{self.increment}"} 42 | if self.starting_fen != "startpos": 43 | self.tags["FEN"] = self.starting_fen 44 | self.round = 0 45 | 46 | self.latest_round_results: List[Tuple[Tuple[int, int, int], Tuple[int, int, int]]] = [] 47 | self.latest_complete_results: List[List[Tuple[Tuple[int, int, int], Tuple[int, int, int]]]] = [] 48 | 49 | def get_complete_pairs(self) -> None: 50 | pairs = self.pairs.copy() 51 | for match in range(self.games_per_pair): 52 | self.complete_pairs.append(pairs.copy()) 53 | pairs = list(map(lambda _pair: (_pair[1], _pair[0]), pairs)) 54 | 55 | def get_engine(self, command: Union[str, List[str]], protocol: str, options: Dict[str, Any], cwd: Optional[str], 56 | ) -> Union[HubEngine, DXPEngine, CheckerBoardEngine]: 57 | engine: Union[HubEngine, DXPEngine, CheckerBoardEngine] 58 | if protocol.lower() == "hub": 59 | engine = HubEngine(command, cwd=cwd) 60 | engine.configure(options) 61 | elif protocol.lower() == "dxp": 62 | options["initial-time"] = self.start_time 63 | options["max-moves"] = self.max_moves 64 | engine = DXPEngine(command, options, cwd=cwd) 65 | elif protocol.lower() == "cb" or protocol.lower() == "checkerboard": 66 | engine = CheckerBoardEngine(command) 67 | engine.configure(options) 68 | else: 69 | raise ValueError(f"There is no protocol `{protocol}`.") 70 | return engine 71 | 72 | def play(self) -> List[int]: 73 | while self.round < self.games_per_pair: 74 | logger.debug(f"Playing round {self.round + 1}/{self.games_per_pair}") 75 | self.play_round() 76 | self.round += 1 77 | for player in range(self.player_count): 78 | self.scores[player] = self.results[player][0] * 2 + self.results[player][1] 79 | return self.scores 80 | 81 | def play_round(self) -> None: 82 | round_results = [] 83 | for game_number, match in enumerate(self.complete_pairs[self.round]): 84 | logger.debug(f"Playing game {game_number+1}/{len(self.complete_pairs[self.round])} in {self.round+1}th round.") 85 | result1, result2 = self.play_game(self.players[match[0]], self.players[match[1]], game_number+1) 86 | round_results.append((result1, result2)) 87 | # Player 1 88 | self.results[match[0]][0] += result1[0] 89 | self.results[match[0]][1] += result1[1] 90 | self.results[match[0]][2] += result1[2] 91 | 92 | # Player 2 93 | self.results[match[1]][0] += result2[0] 94 | self.results[match[1]][1] += result2[1] 95 | self.results[match[1]][2] += result2[2] 96 | self.latest_round_results = round_results.copy() 97 | logger.debug(f"Round results until now: {self.latest_round_results}") 98 | self.complete_results.append(round_results) 99 | self.latest_complete_results = self.complete_results.copy() 100 | logger.debug(f"Complete results until now: {self.latest_complete_results}") 101 | 102 | def play_game(self, player_1_info: Tuple[Union[str, List[str]], str, Dict[str, Any]], 103 | player_2_info: Tuple[Union[str, List[str]], str, Dict[str, Any]], game_number: int 104 | ) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]: 105 | logger.debug(f"Player 1: '{player_1_info[0]}', Player 2: '{player_2_info[0]}'") 106 | board = Board(self.variant, self.starting_fen) 107 | player_1 = self.get_engine(*player_1_info) 108 | player_2 = self.get_engine(*player_2_info) 109 | if isinstance(player_1, HubEngine): 110 | player_1.init() 111 | if isinstance(player_2, HubEngine): 112 | player_2.init() 113 | player_1_limit = Limit(self.start_time, self.increment) 114 | player_2_limit = Limit(self.start_time, self.increment) 115 | max_moves = self.max_moves 116 | if max_moves == 0: 117 | max_moves = 10000 118 | while not board.is_over() and len(board.move_stack) < max_moves: 119 | logger.info(f'move: {len(board.move_stack)}') 120 | if board.turn == WHITE: 121 | start = time.perf_counter_ns() 122 | if isinstance(player_1, HubEngine): 123 | best_move = player_1.play(board, player_1_limit, False) 124 | elif isinstance(player_1, DXPEngine): 125 | best_move = player_1.play(board) 126 | else: # Checkerboard 127 | best_move = player_1.play(board, player_1_limit) 128 | end = time.perf_counter_ns() 129 | player_1_limit.time = player_1_limit.time - (end - start) / 1e9 + player_1_limit.inc 130 | else: 131 | start = time.perf_counter_ns() 132 | if isinstance(player_2, HubEngine): 133 | best_move = player_2.play(board, player_2_limit, False) 134 | elif isinstance(player_2, DXPEngine): 135 | best_move = player_2.play(board) 136 | else: # Checkerboard 137 | best_move = player_2.play(board, player_2_limit) 138 | end = time.perf_counter_ns() 139 | player_2_limit.time = player_2_limit.time - (end - start) / 1e9 + player_2_limit.inc 140 | if best_move.move: 141 | board.push(best_move.move) 142 | else: 143 | break 144 | if not isinstance(player_1, CheckerBoardEngine): 145 | player_1.quit() 146 | if not isinstance(player_2, CheckerBoardEngine): 147 | player_2.quit() 148 | player_1.kill_process() 149 | player_2.kill_process() 150 | winner = board.winner() 151 | game_ending = "1-1" 152 | if winner == WHITE: 153 | game_ending = "2-0" 154 | elif winner == BLACK: 155 | game_ending = "0-2" 156 | 157 | # Write PDN 158 | tags = self.tags.copy() 159 | tags["Result"] = game_ending 160 | tags["Round"] = f"{self.round + 1}.{game_number}" 161 | tags["White"] = str(player_1_info[0]) 162 | tags["Black"] = str(player_2_info[0]) 163 | tags["PlyCount"] = str(len(board.move_stack)) 164 | date_tag, time_tag, utc_date_tag, utc_time_tag = self._get_date_tags() 165 | tags["Date"] = date_tag 166 | tags["Time"] = time_tag 167 | tags["UTCDate"] = utc_date_tag 168 | tags["UTCTime"] = utc_time_tag 169 | PDNWriter(self.filename, board, tags=tags, game_ending=game_ending) 170 | 171 | # Return result 172 | if winner == WHITE: # Player 1 won 173 | logger.debug("Player 1 won") 174 | return (1, 0, 0), (0, 0, 1) 175 | elif winner == BLACK: # player 2 won 176 | logger.debug("Player 2 won") 177 | return (0, 0, 1), (1, 0, 0) 178 | else: # Draw 179 | logger.debug("Game ended in a draw.") 180 | return (0, 1, 0), (0, 1, 0) 181 | 182 | def _get_date_tags(self) -> Tuple[str, str, str, str]: 183 | date = datetime.datetime.now() 184 | year = str(date.year).zfill(4) 185 | month = str(date.month).zfill(2) 186 | day = str(date.day).zfill(2) 187 | hour = str(date.hour).zfill(2) 188 | minute = str(date.minute).zfill(2) 189 | second = str(date.second).zfill(2) 190 | date_tag = f"{year}.{month}.{day}" 191 | time_tag = f"{hour}:{minute}:{second}" 192 | date = datetime.datetime.utcnow() 193 | year = str(date.year).zfill(4) 194 | month = str(date.month).zfill(2) 195 | day = str(date.day).zfill(2) 196 | hour = str(date.hour).zfill(2) 197 | minute = str(date.minute).zfill(2) 198 | second = str(date.second).zfill(2) 199 | utc_date_tag = f"{year}.{month}.{day}" 200 | utc_time_tag = f"{hour}:{minute}:{second}" 201 | return date_tag, time_tag, utc_date_tag, utc_time_tag 202 | 203 | def get_standings(self) -> List[Tuple[int, int]]: 204 | standings = list(zip(self.scores, self.int_players)) 205 | standings.sort(reverse=True) 206 | return standings 207 | 208 | def print_standings(self) -> None: 209 | standings = self.get_standings() 210 | for place, engine in enumerate(standings): 211 | logger.debug(f"{place + 1}th place: {self.players[engine[1]][0]} with {engine[0]} points.") 212 | print(f"{place+1}th place: {self.players[engine[1]][0]} with {engine[0]} points.") 213 | -------------------------------------------------------------------------------- /examples/board.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | a 4 | b 5 | c 6 | d 7 | e 8 | f 9 | g 10 | h 11 | i 12 | j 13 | 10 14 | 9 15 | 8 16 | 7 17 | 6 18 | 5 19 | 4 20 | 3 21 | 2 22 | 1 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /examples/engine_pondering.py: -------------------------------------------------------------------------------- 1 | from draughts.engine import HubEngine, Limit, PlayResult 2 | from draughts import Board 3 | import threading 4 | import time 5 | 6 | game = Board() 7 | engine = HubEngine(["scan.exe", "hub"]) 8 | engine.init() 9 | limit = Limit(time=10) 10 | move = None 11 | 12 | 13 | def ponder_result(game, limit): 14 | global move 15 | move: PlayResult = engine.play(game, limit, True) 16 | 17 | 18 | thr = threading.Thread(target=ponder_result, args=(game, limit)) 19 | thr.start() 20 | time.sleep(2) # Opponent thinking 21 | engine.ponderhit() 22 | thr.join() 23 | 24 | print(move.move.pdn_move) 25 | -------------------------------------------------------------------------------- /other_licenses/ImparaAI checkers LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010-2018 ImparaAI https://impara.ai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /other_licenses/akalverboer DXC100_draughts_client LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Arthur Kalverboer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /other_licenses/fishnet LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Niklas Fiekas 2 | 3 | The MIT license 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pydraughts 3 | version = attr: draughts.__version__ 4 | author = Ioannis Pantidis 5 | description = A draughts library for Python with move generation, SVG visualizations, PDN reading and writing, engine communication and balloted openings 6 | long_description = file: README.md 7 | long_description_content_type = text/markdown 8 | keywords = checkers, draughts, game, fen, pdn 9 | url = https://github.com/AttackingOrDefending/pydraughts 10 | project_urls = 11 | Bug Tracker = https://github.com/AttackingOrDefending/pydraughts/issues 12 | classifiers = 13 | Intended Audience :: Developers 14 | Intended Audience :: Science/Research 15 | Programming Language :: Python :: 3 16 | License :: OSI Approved :: MIT License 17 | Operating System :: OS Independent 18 | Topic :: Games/Entertainment :: Board Games 19 | Topic :: Games/Entertainment :: Turn Based Strategy 20 | Topic :: Software Development :: Libraries :: Python Modules 21 | license_files = 22 | LICENSE 23 | other_licenses/* 24 | 25 | [options] 26 | include_package_data = True 27 | packages = 28 | draughts 29 | draughts.core 30 | draughts.engines 31 | draughts.engines.dxp_communication 32 | draughts.engines.checkerboard_extra 33 | draughts.ballot_files 34 | python_requires = >=3.8 35 | install_requires = 36 | msl-loadlib==0.10.0 37 | -------------------------------------------------------------------------------- /test_pydraughts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AttackingOrDefending/pydraughts/3277325ccc17cca75e46da454dc7fb8c0c32150b/test_pydraughts/__init__.py -------------------------------------------------------------------------------- /test_pydraughts/conftest.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import os 3 | 4 | 5 | def pytest_sessionfinish(session, exitstatus): 6 | if os.path.exists('TEMP'): 7 | shutil.rmtree('TEMP') 8 | -------------------------------------------------------------------------------- /test_pydraughts/test_ambiguous_moves.py: -------------------------------------------------------------------------------- 1 | from draughts import Board, Move 2 | 3 | 4 | def test_ambiguous_move(): 5 | game = Board(fen='W:WK47:B14,19,29,31,42') 6 | big_move = [[47, 33], [33, 24], [24, 13], [13, 36]] 7 | move_move = Move(game, big_move) 8 | assert move_move.pdn_move == "47x38x24x13x36" # Not "47x33x24x13x36" 9 | 10 | board = Board("russian", fen="W:WKd2:Bf6,c5,e5,e3:H0:F1") 11 | legal_moves = list(map(lambda m: m.pdn_move, board.legal_moves())) 12 | assert legal_moves == ['6x15x22x13', '6x15x22x9', '6x20x27x13', '6x20x27x9'] 13 | 14 | board = Board("russian", fen="W:We3,g3,h4,a5,b2,h2,a3,Kf8:Bc5,e5,g5,g7,h8") 15 | legal_moves = list(map(lambda m: m.pdn_move, board.legal_moves())) 16 | assert legal_moves == ['31x13', '31x24x15x22x13', '16x21'] 17 | 18 | board = Board("russian", fen="W:WKd2:Bc5,e5,c3,e3:H0:F1") 19 | legal_moves = list(map(lambda m: m.pdn_move, board.legal_moves())) 20 | assert legal_moves == ['6x13x22x15x6', '6x2', '6x15x22x13x6', '6x3'] 21 | 22 | board = Board(fen="W:WK38:B18,19,32,33:H0:F1") 23 | legal_moves = list(map(lambda m: m.pdn_move, board.legal_moves())) 24 | assert legal_moves == ['38x27x13x24x38', '38x42', '38x47', '38x24x13x27x38', '38x43', '38x49'] 25 | -------------------------------------------------------------------------------- /test_pydraughts/test_ballots.py: -------------------------------------------------------------------------------- 1 | from draughts.ballots import Ballots 2 | from draughts import Board, Move 3 | 4 | 5 | def test_ballots(): 6 | ballots = Ballots('english', moves=2, include_lost_games=True) 7 | ballots_returned = set() 8 | for _ in range(60): 9 | ballots_returned.add(ballots.get_ballot()) 10 | assert len(ballots_returned) == 49 11 | 12 | ballots = Ballots('english', moves=3) 13 | ballots = Ballots('english', moves=4) 14 | ballots = Ballots('english', moves=5) 15 | ballots = Ballots('english', eleven_pieces=True) 16 | ballots = Ballots('italian', eleven_pieces=True) 17 | ballots = Ballots('russian') 18 | ballots = Ballots('brazilian') 19 | ballots = Ballots('russian', basic_positions=True) 20 | 21 | ballots = Ballots('english', moves=2, include_lost_games=True) 22 | for key, fen in ballots.positions.items(): 23 | moves = key.split()[1:] 24 | board1 = Board('english', fen) 25 | board2 = Board('english') 26 | for move in moves: 27 | board2.push(Move(board2, pdn_move=move)) 28 | assert board1._game.get_fen() == board2._game.get_fen() 29 | -------------------------------------------------------------------------------- /test_pydraughts/test_board.py: -------------------------------------------------------------------------------- 1 | from draughts import Board 2 | from draughts.core.board import Board as InternalBoard 3 | 4 | 5 | def test_board(): 6 | game = Board() 7 | game._game.board = InternalBoard() 8 | assert game.fen == 'W:W31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50:B1,10,11,12,13,14,15,16,17,18,19,2,20,3,4,5,6,7,8,9' 9 | assert game._game.board.count_movable_player_pieces() == 5 10 | game._game.board, _ = game._game.board.create_new_board_from_move([35, 30], 1, []) 11 | assert game._game.get_li_fen() == 'B:W30,31,32,33,34,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50:B1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20' 12 | 13 | game = Board(fen='W:W29,31,32,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50:B1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,19,20,23') 14 | game._game.get_possible_moves() 15 | game._game.board, _ = game._game.board.create_new_board_from_move([29, 18], 1, []) 16 | assert game._game.get_li_fen() == 'B:W18,31,32,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50:B1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,19,20' 17 | -------------------------------------------------------------------------------- /test_pydraughts/test_convert.py: -------------------------------------------------------------------------------- 1 | from draughts.convert import fen_from_variant, fen_to_variant, move_from_variant, move_to_variant, _rotate_move, _algebraic_to_number, _number_to_algebraic, _numeric_to_algebraic_square 2 | 3 | 4 | def test_convert(): 5 | assert fen_from_variant('B:W19,21,22,23,25,26,27,29-31,32:B1,2,3-5,6,7,9,10,11,12', 6 | variant='english') == 'W:W21,22,23,24,26,27,28,29,30,31,32:B1,10,11,12,14,2,3,4,6,7,8' 7 | assert fen_from_variant('W:Wa3,Kc3,Ke3,g3,b2,d2,f2,h2,a1,c1,e1,g1:Bb8,d8,f8,h8,a7,c7,e7,g7,b6,Kd6,Kf6,h6', 8 | variant='russian') == 'W:W21,24,25,26,27,28,29,30,31,32,K22,K23:B1,12,2,3,4,5,6,7,8,9,K10,K11' 9 | assert fen_from_variant('W:WK12-14:BK28-31', variant='standard') == 'W:WK12,K13,K14:BK28,K29,K30,K31' 10 | assert fen_to_variant('W:W21,22,23,24,26,27-29,30,31,32:B1,10,11,12,14,2-4,6,7,8', 11 | variant='english') == 'B:W19,21,22,23,25,26,27,29,30,31,32:B1,10,11,12,2,3,4,5,6,7,9' 12 | assert fen_to_variant('B:W17,22,23,24,25,26,27-29,30,31,32:B1,2,3-5,6,7,8,9,10,11,12', 13 | variant='russian') == 'B:Wa1,c3,e3,g3,b4,c1,e1,g1,b2,d2,f2,h2:Bb6,d6,f6,h6,a7,c7,e7,g7,b8,d8,f8,h8' 14 | assert fen_to_variant('W:WK12-14:BK28-31', variant='russian') == 'W:WKa5,Kc5,Kh6:BKa1,Kc1,Ke1,Kh2' 15 | 16 | assert move_from_variant('g3-h4', variant='russian') == '24-20' 17 | assert move_to_variant('24-20', variant='russian') == 'g3-h4' 18 | assert _rotate_move('50-45', 3) == '46-41' 19 | assert _algebraic_to_number('a1a2', variant='turkish') == '1-9' 20 | assert _number_to_algebraic('1-9', variant='turkish') == 'a1-a2' 21 | assert _number_to_algebraic('a1-a2', variant='turkish') == 'a1-a2' 22 | assert _numeric_to_algebraic_square('32', width=4) == 'h8' 23 | assert _numeric_to_algebraic_square('h8', width=4) == 'h8' 24 | -------------------------------------------------------------------------------- /test_pydraughts/test_dxp_communication.py: -------------------------------------------------------------------------------- 1 | from draughts.engines.dxp_communication.dxp_classes import DamExchange 2 | from draughts import Board 3 | import logging 4 | 5 | logging.basicConfig() 6 | logger = logging.getLogger("pydraughts") 7 | logger.setLevel(logging.DEBUG) 8 | 9 | 10 | def test_dam_exchange(): 11 | dam_exchange = DamExchange() 12 | 13 | assert dam_exchange.parse('CMESSAGE') == {'type': 'C', 'text': 'MESSAGE'} # CHAT 14 | assert dam_exchange.parse('B001W') == {'type': 'B', 'moveId': '001', 'mColor': 'W'} # BACKREQ 15 | assert dam_exchange.parse('K1') == {'type': 'K', 'accCode': '1'} # BACKACC 16 | assert dam_exchange.parse('P0') == {'type': '?'} # Unknown 17 | assert dam_exchange.parse('E11') == {'type': 'E', 'reason': '1', 'stop': '1'} # GAMEEND 18 | assert (dam_exchange.parse('ANAME 1') == 19 | {'type': 'A', 'engineName': 'NAME', 'accCode': '1'}) # GAMEACC 20 | assert (dam_exchange.parse('M000135240130') == 21 | {'type': 'M', 'time': '0001', 'from': '35', 'to': '24', 'nCaptured': '01', 'captures': ['30']}) # MOVE 22 | assert (dam_exchange.parse(f'R01NAME Z010100BW{Board()._game.get_dxp_fen()}') == 23 | {'type': 'R', 'name': 'NAME', 'fColor': 'Z', 'gameTime': '010', 'numMoves': '100', 'posInd': 'B', 24 | 'mColor': 'W', 'pos': 'zzzzzzzzzzzzzzzzzzzzeeeeeeeeeewwwwwwwwwwwwwwwwwwww'}) # GAMEREQ 25 | 26 | assert (dam_exchange.parse(dam_exchange.msg_gamereq(0, 100, 100, Board(), 0)) == 27 | {'type': 'R', 'name': 'DXP Client', 'fColor': 'Z', 'gameTime': '100', 'numMoves': '100', 'posInd': 'B', 28 | 'mColor': 'W', 'pos': 'zzzzzzzzzzzzzzzzzzzzeeeeeeeeeewwwwwwwwwwwwwwwwwwww'}) # GAMEREQ 29 | assert dam_exchange.msg_gamereq(0, 100, 100) == 'R01DXP Client Z100100A' # GAMEREQ 30 | assert dam_exchange.parse(dam_exchange.msg_backreq(1, 1)) == {'type': 'B', 'moveId': '001', 'mColor': 'Z'} # BACKREQ 31 | assert dam_exchange.msg_backreq(1, 0) == 'B001W' # BACKREQ 32 | assert dam_exchange.parse(dam_exchange.msg_backacc('1')) == {'type': 'K', 'accCode': '1'} # BACKACC 33 | assert dam_exchange.msg_backacc('0') == 'K0' # BACKACC 34 | assert dam_exchange.msg_chat("chat") == "Cchat" # CHAT 35 | -------------------------------------------------------------------------------- /test_pydraughts/test_game.py: -------------------------------------------------------------------------------- 1 | from draughts import Board, Move, WHITE 2 | 3 | 4 | def test_game(): 5 | assert Board('breakthrough', 'B:WK4,31,35,36,38,40,43,44,45,46,47,48,49,50:B1,2,3,6,7,8,9,11,13,16').is_over() 6 | assert Board('breakthrough', 'B:WK4,31,35,36,38,40,43,44,45,46,47,48,49,50:B1,2,3,6,7,8,9,11,13,16').winner() == WHITE 7 | 8 | game = Board(variant='from position', fen='B:W27,28,32,34,35,37,39,40,42,43,44,45,46,47,48,49,50:B1,2,3,4,5,6,7,8,9,10,12,13,15,16,19,23,24') 9 | moves = game.legal_moves() 10 | captures = list(map(lambda move: move.captures, moves)) 11 | moves = list(map(lambda move: move.board_move, moves)) 12 | correct_moves = [[[15, 20]], [[10, 14]], [[9, 14]], [[24, 30]], [[24, 29]], [[23, 29]], [[13, 18]], [[12, 18]], [[12, 17]], [[7, 11]], [[6, 11]], [[16, 21]]] 13 | correct_moves.sort() 14 | correct_captures = [[]] * 12 15 | assert moves == correct_moves and captures == correct_captures 16 | 17 | assert not game._game.board.is_valid_row_and_column(11, 0) 18 | assert not game._game.board.is_valid_row_and_column(5, 8) 19 | assert game._game.board.is_valid_row_and_column(5, 4) 20 | try: 21 | game._game.push([1, 46]) 22 | assert False 23 | except ValueError: 24 | assert True 25 | 26 | game = Board(fen='W') 27 | assert game.legal_moves() == [] 28 | game = Board() 29 | game._game.push_str_move('3530') 30 | game = Board(fen='W:W1-40:B41-50') 31 | game._game = game._game.copy() 32 | assert game.fen == 'W:W1,10,11,12,13,14,15,16,17,18,19,2,20,21,22,23,24,25,26,27,28,29,3,30,31,32,33,34,35,36,37,38,39,4,40,5,6,7,8,9:B41,42,43,44,45,46,47,48,49,50' 33 | assert game._game.board.pieces[0].get_diagonal_one_square_behind_enemy(game._game.board.pieces[10]) == [] 34 | 35 | game = Board(fen='W:WK12-14:BK28-31') 36 | assert game.fen == 'W:WK12,K13,K14:BK28,K29,K30,K31' 37 | 38 | # Test all legal move conditions for italian 39 | 40 | # All rules except for 2 are tested by both fens, so the specific rule each fen tests is mentioned in a comment. 41 | # This fen tests the rule "The capture sequence that captures the most kings has to be played." 42 | game = Board('italian', 'W:W31,32,K25:B12,20,21,28,5,K13,K14,K7') 43 | assert list(map(lambda move: move.board_move, game.legal_moves())) == [[[25, 18], [18, 11], [11, 4]]] 44 | # This fen tests the rule "The capture sequence where the king occurs first has to be played." 45 | game = Board('italian', 'W:W31,32,K25:B12,13,20,21,28,7,K14,K5') 46 | assert list(map(lambda move: move.board_move, game.legal_moves())) == [[[25, 18], [18, 11], [11, 4]]] 47 | 48 | game = Board(fen='W:W6:B1') 49 | assert game._game.legal_moves() == ([], []) 50 | 51 | # Test pop() 52 | game = Board() 53 | game.push(Move(steps_move=[35, 30])) 54 | game.pop() 55 | game.push(Move(steps_move=[35, 30])) 56 | game.push(Move(steps_move=[19, 24])) 57 | game.pop() 58 | game.push(Move(steps_move=[19, 24])) 59 | game.push(Move(steps_move=[30, 19])) 60 | game.pop() 61 | game.pop() 62 | game.pop() 63 | game.push(Move(steps_move=[35, 30])) 64 | game.push(Move(steps_move=[19, 24])) 65 | game.push(Move(steps_move=[30, 19])) 66 | 67 | game = Board(fen='W:WK44:B9,18,33') 68 | game.push(Move(steps_move=[44, 22])) 69 | game.push(Move(steps_move=[22, 13])) 70 | game.pop() 71 | assert game._game._not_added_move == [] 72 | assert game._game.get_fen() == 'WeeeeeeeebeeeeeeeebeeeeeeeeeeeeeebeeeeeeeeeeWeeeeee' 73 | 74 | game = Board(fen='W:WK43:BK9') 75 | game.push(Move(steps_move=[43, 49])) 76 | game.pop() 77 | assert game._game.reversible_moves == [] 78 | 79 | game = Board(fen='W:WK39:B23,33') 80 | _, captures = game._game.push([[39, 28], [28, 19]]) 81 | assert captures == [33, 23] 82 | 83 | game = Board() 84 | game.null() 85 | assert game._game.get_fen() == 'Bbbbbbbbbbbbbbbbbbbbbeeeeeeeeeewwwwwwwwwwwwwwwwwwww' 86 | assert game.move_stack[0].pdn_move == '0-0' 87 | 88 | game = Board('frisian', 'W:WK4,36,41,42,43,44,46,47,48,49,50:B1,2,6,12,14,17,18,23') 89 | assert (list(map(lambda move: move.board_move, game.legal_moves())) == 90 | [[[4, 24], [24, 22], [22, 11], [11, 13], [13, 22]], [[4, 24], [24, 22], [22, 11], [11, 13], [13, 27]], 91 | [[4, 24], [24, 22], [22, 11], [11, 13], [13, 31]], [[4, 24], [24, 22], [22, 13], [13, 11], [11, 22]], 92 | [[4, 24], [24, 22], [22, 13], [13, 11], [11, 28]], [[4, 24], [24, 22], [22, 13], [13, 11], [11, 33]], 93 | [[4, 24], [24, 22], [22, 13], [13, 11], [11, 39]]]) 94 | 95 | assert Board(fen='W:WKa1,K8,9:BK7,Kb2,23').fen == 'W:W9,K1,K8:B23,K6,K7' 96 | 97 | game = Board() 98 | game._game.move([31, 27], include_pdn=True) 99 | game._game.null() 100 | assert game._game.is_over() is False 101 | assert game._game.li_fen_to_hub_fen('W:WK1-3:BK4-6') == 'WWWWBBBeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' 102 | 103 | # From https://github.com/AttackingOrDefending/pydraughts/issues/28 104 | game = Board('russian', 'W:Wh6:Bg7,c5,d2') 105 | assert len(game.legal_moves()) == 1 106 | assert game.legal_moves()[0].board_move == [[24, 31], [31, 13], [13, 3]] 107 | 108 | 109 | def fifty_square_draw_board(game, repeat_time=12, half_time=True): 110 | for _ in range(repeat_time): 111 | game.push(Move(steps_move=[28, 33])) 112 | game.push(Move(steps_move=[1, 7])) 113 | game.push(Move(steps_move=[33, 28])) 114 | game.push(Move(steps_move=[7, 1])) 115 | if half_time: 116 | game.push(Move(steps_move=[28, 33])) 117 | game.push(Move(steps_move=[1, 7])) 118 | return game 119 | 120 | 121 | def thirtytwo_square_draw_board(game, repeat_time=7, half_time=True): 122 | for _ in range(repeat_time): 123 | game._game.push([[32, 27]]) 124 | game._game.push([[1, 6]]) 125 | game._game.push([[27, 32]]) 126 | game._game.push([[6, 1]]) 127 | if half_time: 128 | game._game.push([[32, 27]]) 129 | game._game.push([[1, 6]]) 130 | return game 131 | 132 | 133 | def test_drawing_conditions(): 134 | # 25 consecutive non-capture king moves. 135 | game = Board(fen='W:WK28:BK1') 136 | game = fifty_square_draw_board(game) 137 | assert game.winner() == 0 138 | game = Board(fen='B:WK1:BK28') 139 | game = fifty_square_draw_board(game) 140 | assert game.winner() == 0 141 | 142 | # 1 king vs 3 pieces (with at least 1 king) and 16 moves made. 143 | game = Board(fen='W:WK28:BK1,2,3') 144 | game = fifty_square_draw_board(game, 8, False) 145 | assert game.winner() == 0 146 | game = Board(fen='B:WK1,K2,K3:BK28') 147 | game = fifty_square_draw_board(game, 8, False) 148 | assert game.winner() == 0 149 | 150 | # 1 king vs 2 or fewer pieces (with at least 1 king) and 5 moves made. 151 | game = Board(fen='W:WK28:BK1,2') 152 | game = fifty_square_draw_board(game, 2) 153 | assert game.winner() == 0 154 | game = Board(fen='B:WK1,K2:BK28') 155 | game = fifty_square_draw_board(game, 2) 156 | assert game.winner() == 0 157 | 158 | # 2 kings vs 1 king and 7 moves made. 159 | game = Board('frisian', 'W:WK28:BK1,K2') 160 | game = fifty_square_draw_board(game, 3) 161 | assert game.winner() == 0 162 | game = Board('frisian', 'B:WK1,K2:BK28') 163 | game = fifty_square_draw_board(game, 3) 164 | assert game.winner() == 0 165 | 166 | # 3 or more kings vs 1 king and 15 moves made. 167 | game = Board('russian', 'W:WKg1:BKb8,Kd8,Kf8') 168 | game = thirtytwo_square_draw_board(game) 169 | assert game.winner() == 0 170 | game = Board('russian', 'B:WKb8,Kd8,Kf8:BKg1') 171 | game = thirtytwo_square_draw_board(game) 172 | assert game.winner() == 0 173 | 174 | # 15 consecutive non-capture king moves. 175 | game = Board('russian', 'W:WKg1:BKb8') 176 | game = thirtytwo_square_draw_board(game) 177 | assert game.winner() == 0 178 | 179 | # Same number of kings, same number of pieces, 4 or 5 pieces per side and 30 moves made. 180 | game = Board('russian', 'W:Wa1,c1,e1,Kg1:BKb8,d8,f8,h8') 181 | game._game.moves_since_last_capture = 60 182 | assert game.winner() == 0 183 | 184 | # Same number of kings, same number of pieces, 6 or 7 pieces per side and 60 moves made. 185 | game = Board('russian', 'W:Wb2,d2,a1,c1,e1,Kg1:BKb8,d8,f8,h8,e7,g7') 186 | game._game.moves_since_last_capture = 120 187 | assert game.winner() == 0 188 | 189 | # 3 pieces (with at least 1 king) vs 1 king on the long diagonal. 190 | game = Board('russian', 'W:WKa7,b4,f4:BKa1') 191 | for _ in range(2): 192 | game._game.push([[5, 9]]) 193 | game._game.push([[29, 4]]) 194 | game._game.push([[9, 5]]) 195 | game._game.push([[4, 29]]) 196 | game._game.push([[5, 9]]) 197 | game._game.push([[29, 4]]) 198 | assert game.winner() == 0 199 | game = Board('russian', 'B:WKa1:BKa7,b4,f4') 200 | for _ in range(2): 201 | game._game.push([[5, 9]]) 202 | game._game.push([[29, 4]]) 203 | game._game.push([[9, 5]]) 204 | game._game.push([[4, 29]]) 205 | game._game.push([[5, 9]]) 206 | game._game.push([[29, 4]]) 207 | assert game.winner() == 0 208 | 209 | # 2 pieces (with at least 1 king) vs 1 king and 5 moves made. 210 | game = Board('russian', 'W:WKg1:BKb8,h8') 211 | game = thirtytwo_square_draw_board(game, 2) 212 | assert game.winner() == 0 213 | game = Board('russian', 'B:WKb8,a1:BKg1') 214 | game = thirtytwo_square_draw_board(game, 2) 215 | assert game.winner() == 0 216 | 217 | game = Board('english', 'B:WK32:BK1') 218 | game = thirtytwo_square_draw_board(game, 20, False) 219 | assert game.winner() == 0 220 | 221 | game = Board('turkish', 'W:WKh5:Bb7') 222 | game._game.push([[32, 31]]) 223 | game._game.push([[10, 9]]) 224 | game._game.push([[31, 32]]) 225 | game._game.push([[9, 10]]) 226 | assert game.winner() == 0 227 | -------------------------------------------------------------------------------- /test_pydraughts/test_move.py: -------------------------------------------------------------------------------- 1 | from draughts import Board, Move 2 | from draughts.core.game import Game 3 | from draughts.core.move import StandardMove 4 | 5 | 6 | def test_move(): 7 | game = Board(fen='W:W42:B28,38') 8 | assert Move(game, steps_move=[42, 33, 22]).board_move == [[42, 33], [33, 22]] 9 | assert Move(game, li_api_move=['4233', '3322']).board_move == [[42, 33], [33, 22]] 10 | assert Move(game, li_one_move='423322').board_move == [[42, 33], [33, 22]] 11 | 12 | assert Move(steps_move=[42, 33, 22]).board_move == [[42, 33], [33, 22]] 13 | assert Move(li_api_move=['4233', '3322']).board_move == [[42, 33], [33, 22]] 14 | assert Move(li_one_move='423322').board_move == [[42, 33], [33, 22]] 15 | 16 | assert Move(hub_position_move='3530').hub_move == '35-30' 17 | assert Move(hub_move='42x33x22').hub_position_move == '423322' 18 | assert Move(hub_move='35-30').hub_position_move == '3530' 19 | 20 | assert Move(pdn_position_move='3530').pdn_move == '35-30' 21 | 22 | # From https://github.com/AttackingOrDefending/pydraughts/issues/23 Converted to board 23 | game = Board(variant='brazilian', fen='B:Wf6,b4,d4,a3,b2,d2,h2,a1,c1,e1,g1:Bb8,d8,f8,h8,c7,e7,g7,b6,d6,h6') 24 | move_ordered = Move(board=game, hub_move='28x17x13x14x23') 25 | move_unordered = Move(board=game, hub_move='28x17x14x13x23') 26 | assert move_ordered.board_move == move_unordered.board_move 27 | 28 | 29 | def test_standard_move(): 30 | game = Game(fen='W:W42:B28,38') 31 | assert StandardMove(game, steps_move=[42, 33, 22]).board_move == [[42, 33], [33, 22]] 32 | assert StandardMove(game, li_api_move=['4233', '3322']).board_move == [[42, 33], [33, 22]] 33 | assert StandardMove(game, li_one_move='423322').board_move == [[42, 33], [33, 22]] 34 | 35 | assert StandardMove(steps_move=[42, 33, 22]).board_move == [[42, 33], [33, 22]] 36 | assert StandardMove(li_api_move=['4233', '3322']).board_move == [[42, 33], [33, 22]] 37 | assert StandardMove(li_one_move='423322').board_move == [[42, 33], [33, 22]] 38 | 39 | assert StandardMove(hub_position_move='3530').hub_move == '35-30' 40 | assert StandardMove(hub_move='42x33x22').hub_position_move == '423322' 41 | assert StandardMove(hub_move='35-30').hub_position_move == '3530' 42 | 43 | assert StandardMove(pdn_position_move='3530').pdn_move == '35-30' 44 | 45 | # From https://github.com/AttackingOrDefending/pydraughts/issues/23 46 | game = Game(variant='brazilian', fen='Bbbbbebbbbbwbeeeewweeweeewwewwwww') 47 | move_ordered = StandardMove(board=game, hub_move='8x13x11x17x18') 48 | move_unordered = StandardMove(board=game, hub_move='8x13x11x18x17') 49 | assert move_ordered.board_move == move_unordered.board_move 50 | assert StandardMove(Game(), hub_move='31-27').hub_position_move == '3127' 51 | assert StandardMove(Game(), pdn_move='31-27').pdn_position_move == '3127' 52 | assert StandardMove(Game(fen='W:WK47:B14,19,29,31,42'), pdn_move='47x38x24x13x36').pdn_position_move == '4738241336' 53 | assert StandardMove(Game(fen='W:WK47:B14,19,29,31,42'), pdn_position_move='4738200936').pdn_move == '47x38x20x9x36' 54 | -------------------------------------------------------------------------------- /test_pydraughts/test_pdn.py: -------------------------------------------------------------------------------- 1 | from draughts import Move, Board 2 | from draughts.PDN import PDNReader, PDNWriter 3 | 4 | import requests 5 | import zipfile 6 | import os 7 | import shutil 8 | import logging 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def download_games(): 14 | headers = {'User-Agent': 'User Agent', 'From': 'mail@mail.com'} 15 | response = requests.get('https://github.com/wiegerw/pdn/raw/refs/heads/master/games/games.zip', headers=headers, allow_redirects=True) 16 | with open('./TEMP/games.zip', 'wb') as file: 17 | file.write(response.content) 18 | with zipfile.ZipFile('./TEMP/games.zip', 'r') as zip_ref: 19 | zip_ref.extractall('./games/') 20 | 21 | 22 | if os.path.exists('TEMP'): 23 | shutil.rmtree('TEMP') 24 | os.mkdir('TEMP') 25 | try: 26 | download_games() 27 | except Exception: 28 | download_games() # Attempt to download twice if there are problems. 29 | 30 | 31 | def test_pdn_reading(): 32 | files = os.listdir('./games/succeed') 33 | for file in files: 34 | filepath = os.path.realpath(f'./games/succeed/{file}') 35 | PDNReader(filename=filepath) 36 | 37 | 38 | def test_pdn_writing(): 39 | filepath = os.path.realpath('./games/succeed/mrcd2000kval.pdn') 40 | games = PDNReader(filename=filepath, encodings='utf8') 41 | one_game = games.games[1] 42 | assert one_game.get_titles() == ["", ""] 43 | assert one_game.get_ratings() == ["", ""] 44 | assert one_game.get_na() == ["", ""] 45 | assert one_game.get_types() == ["", ""] 46 | moves = one_game.moves 47 | original_moves = moves 48 | game = Board(variant='russian') 49 | for move in moves: 50 | game.push(Move(pdn_move=move, board=game, variant=game.variant)) 51 | 52 | PDNWriter('pdn_writer.pdn', game, game_ending=one_game.game_ending) 53 | 54 | with open('pdn_writer.pdn') as file: 55 | data = file.read().split('\n') 56 | data = list(filter(lambda line: bool(line) and not line.startswith('['), data)) 57 | moves = data[0] 58 | 59 | correct_moves = '1. c3-d4 f6-e5 2. d4xf6 g7xe5 3. b2-c3 h8-g7 4. g3-f4 e5xg3 5. h2xf4 g7-f6 6. a1-b2 b6-a5 7. e3-d4 d6-e5 8. f4xd6 c7xe5 9. d4-c5 b8-c7 10. d2-e3 e5-f4 11. e3xg5 h6xf4 12. c3-d4 c7-b6 13. c1-d2 f6-e5 14. d4xf6 e7xg5 15. f2-e3 b6xf2 16. e1xe5 a7-b6 17. e5-d6 f8-e7 18. d6xf8 b6-c5 19. f8xb4 a5xe1 20. g1-f2 e1xg3 0-1' 60 | assert moves == correct_moves 61 | 62 | PDNWriter('pdn_writer_moves.pdn', moves=original_moves, game_ending=one_game.game_ending, starting_fen="") 63 | 64 | with open('pdn_writer_moves.pdn') as file: 65 | data = file.read().split('\n') 66 | data = list(filter(lambda line: bool(line) and not line.startswith('['), data)) 67 | moves = data[0] 68 | 69 | correct_moves = '1. c3-d4 f6-e5 2. d4xf6 g7xe5 3. b2-c3 h8-g7 4. g3-f4 e5xg3 5. h2xf4 g7-f6 6. a1-b2 b6-a5 7. e3-d4 d6-e5 8. f4xd6 c7xe5 9. d4-c5 b8-c7 10. d2-e3 e5-f4 11. e3xg5 h6xf4 12. c3-d4 c7-b6 13. c1-d2 f6-e5 14. d4xf6 e7xg5 15. f2-e3 b6xd4xf2 16. e1xg3xe5 a7-b6 17. e5-d6 f8-e7 18. d6xf8 b6-c5 19. f8xb4 a5xc3xe1 20. g1-f2 e1xg3 0-1' 70 | assert moves == correct_moves 71 | 72 | for variant in ["frysk!", "turkish", "brazilian", "english", "standard"]: 73 | pdn_writer = PDNWriter('pdn_writer_minor_test.pdn', variant=variant, moves=[]) 74 | # We use [1:] in the fen, so we exclude the starting color, because internally white always starts, so Game will 75 | # always return a fen with white starting, even in english. 76 | assert pdn_writer._startpos_to_fen() == Board(variant).fen 77 | 78 | PDNWriter('pdn_writer_minor_test_2.pdn', moves=["35-30"]) 79 | with open('pdn_writer_minor_test_2.pdn') as file: 80 | data = file.read().split('\n') 81 | data = list(filter(lambda line: bool(line) and not line.startswith('['), data)) 82 | moves = data[0] 83 | 84 | correct_moves = '1. 35-30 *' 85 | assert moves == correct_moves 86 | 87 | PDNWriter('pdn_writer_minor_test_3.pdn', moves=["20-25"], starting_fen='B:W31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50:B1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20:H0:F1') 88 | with open('pdn_writer_minor_test_3.pdn') as file: 89 | data = file.read().split('\n') 90 | data = list(filter(lambda line: bool(line) and not line.startswith('['), data)) 91 | moves = data[0] 92 | 93 | correct_moves = '1... 20-25 *' 94 | assert moves == correct_moves 95 | -------------------------------------------------------------------------------- /test_pydraughts/test_piece.py: -------------------------------------------------------------------------------- 1 | from draughts import Board 2 | 3 | 4 | def test_piece(): 5 | game = Board(fen='W:WK28:B19,37') 6 | assert (list(map(lambda move: move.board_move, game.legal_moves())) == 7 | [[[28, 14]], [[28, 10]], [[28, 5]], [[28, 41]], [[28, 46]]]) 8 | -------------------------------------------------------------------------------- /test_pydraughts/test_repr.py: -------------------------------------------------------------------------------- 1 | from draughts import Board 2 | 3 | 4 | def test_repr(): 5 | game = Board(fen="B:W27,34,35,36,37,39,40,41,42,43,44,45,46,47,48,49,50:B1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20") 6 | assert str(game) == """ | b | | b | | b | | b | | b 7 | --------------------------------------- 8 | b | | b | | b | | b | | b | 9 | --------------------------------------- 10 | | b | | b | | b | | b | | b 11 | --------------------------------------- 12 | b | | | | b | | b | | b | 13 | --------------------------------------- 14 | | | | | | | | | | 15 | --------------------------------------- 16 | | | w | | | | | | | 17 | --------------------------------------- 18 | | | | | | | | w | | w 19 | --------------------------------------- 20 | w | | w | | | | w | | w | 21 | --------------------------------------- 22 | | w | | w | | w | | w | | w 23 | --------------------------------------- 24 | w | | w | | w | | w | | w | """ 25 | game = Board(variant="italian", fen="W:W29,32:BK15") 26 | assert str(game) == """ | | | | | | | 27 | ------------------------------- 28 | | | | | | | | 29 | ------------------------------- 30 | | | | | | | | 31 | ------------------------------- 32 | | | | | | B | | 33 | ------------------------------- 34 | | | | | | | | 35 | ------------------------------- 36 | | | | | | | | 37 | ------------------------------- 38 | | | | | | | | 39 | ------------------------------- 40 | | w | | | | | | w """ 41 | game = Board(variant="english", fen="W:W29,32:BK14") 42 | assert str(game) == """ | w | | | | | | w 43 | ------------------------------- 44 | | | | | | | | 45 | ------------------------------- 46 | | | | | | | | 47 | ------------------------------- 48 | | | | | | | | 49 | ------------------------------- 50 | | | | | | B | | 51 | ------------------------------- 52 | | | | | | | | 53 | ------------------------------- 54 | | | | | | | | 55 | ------------------------------- 56 | | | | | | | | """ 57 | game = Board(variant="russian", fen="W:Wb2,Kc3:Bd4,e5,b8,f8") 58 | assert str(game) == """ | b | | | | b | | 59 | ------------------------------- 60 | | | | | | | | 61 | ------------------------------- 62 | | | | | | | | 63 | ------------------------------- 64 | | | | | b | | | 65 | ------------------------------- 66 | | | | b | | | | 67 | ------------------------------- 68 | | | W | | | | | 69 | ------------------------------- 70 | | w | | | | | | 71 | ------------------------------- 72 | | | | | | | | """ 73 | game = Board(variant="turkish", fen="W:Wa3,Kb3:Ba7,c7") 74 | assert str(game) == """ | | | | | | | 75 | ------------------------------- 76 | b | | b | | | | | 77 | ------------------------------- 78 | | | | | | | | 79 | ------------------------------- 80 | | | | | | | | 81 | ------------------------------- 82 | | | | | | | | 83 | ------------------------------- 84 | w | W | | | | | | 85 | ------------------------------- 86 | | | | | | | | 87 | ------------------------------- 88 | | | | | | | | """ 89 | -------------------------------------------------------------------------------- /test_pydraughts/test_svg.py: -------------------------------------------------------------------------------- 1 | from draughts import Board, Move 2 | from draughts.svg import create_svg 3 | 4 | 5 | def test_svg(): 6 | board = Board(fen="W:WK4,19,27,33,34,47:B11,12,17,22,25,26:H0:F50") 7 | board.push(Move(board, pdn_move="27x16")) 8 | svg = create_svg(board) 9 | assert svg == """ 10 | 11 | a 12 | b 13 | c 14 | d 15 | e 16 | f 17 | g 18 | h 19 | i 20 | j 21 | 10 22 | 9 23 | 8 24 | 7 25 | 6 26 | 5 27 | 4 28 | 3 29 | 2 30 | 1 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | """ 149 | -------------------------------------------------------------------------------- /test_pydraughts/test_tournament.py: -------------------------------------------------------------------------------- 1 | from draughts.tournament import RoundRobin 2 | import pytest 3 | import sys 4 | import logging 5 | platform = sys.platform 6 | file_extension = '.exe' if platform == 'win32' else '' 7 | 8 | logging.basicConfig() 9 | logger = logging.getLogger("pydraughts") 10 | logger.setLevel(logging.DEBUG) 11 | 12 | 13 | @pytest.mark.timeout(300, method="thread") 14 | def test_tournament(): 15 | if platform != 'win32': 16 | assert True 17 | return 18 | players = [(["scan.exe", "dxp"], "dxp", {'engine-opened': False}, None), ("kr_hub.exe", "hub", {}, None)] 19 | tournament = RoundRobin("tournament.pdn", players, 20, .2) 20 | scores = tournament.play() 21 | logger.debug(f"Scores: {scores}") 22 | tournament.print_standings() 23 | -------------------------------------------------------------------------------- /test_pydraughts/test_variants.py: -------------------------------------------------------------------------------- 1 | import draughts 2 | from draughts import Board, Move 3 | 4 | 5 | def play_game(moves, variant): 6 | game = Board(variant) 7 | for move in moves: 8 | assert game.winner() is None 9 | game.push(Move(pdn_move=move, board=game)) 10 | game.winner() 11 | return game 12 | 13 | 14 | def test_variants(): 15 | # Standard 16 | standard_moves = '32-28 17-21 34-29 21-26 40-34 16-21 37-32 26x37 42x31 21-26 47-42 26x37 42x31 11-17 44-40 6-11 41-37 17-21 31-26 21-27 32x21 18-23 29x18 12x41 46x37 13-18 38-32 9-13 43-38 19-23 49-43 14-19 34-29 23x34 39x30 20-25 50-44 25x34 40x29 10-14 33-28 7-12 21-16 2-7 28-23 19x28 32x23 14-20 29-24 20x29 23x34 5-10 37-32 15-20 44-39 13-19 36-31 11-17 38-33 8-13 45-40 19-24 43-38 13-19 32-28 18-23 28-22 17x28 33x22 20-25 48-42 12-18 22x13 19x8 39-33 10-14 33-29 24x33 38x18 4-9 42-38 14-19 38-33 19-24 26-21 8-13 18-12 7x18 34-29 18-22 29x20 25x14 16-11 13-18 40-34 9-13 33-29 14-19 21-16 22-27 31x22 18x27 34-30 27-32 29-24 32-37 24-20 37-41 20-15 41-46 15-10 19-24 30x8 3x12 10-4 1-6 4-13 6x17 35-30 46-41 30-25 41-47 13-27 17-21 16-11 21x32 11-6 47-15 6-1 12-17 25-20 15x24 1-40 17-22 40-35 24-29 35-13 22-27 13x31 32-38 31-27 38-42 27-32 42-48 32-28 48-34 28-19 34-25 19-28'.split() 17 | play_game(standard_moves, 'standard') 18 | # Frisian 19 | frisian_moves = '32-27 19-23 27-21 17x26 31-27 26x28 33x24 20x29 39x17 12x21 34-30 21-26 30-25 13-18 38-32 8-12 40-34 12-17 34-30 7-12 43-38 14-20 25x14 9x40 45x34 17-21 34-30 18-22 30-25 12-17 32x12 17x8 38-33 21-27 37x17 11x22 33-29 22-27 29-24 2-7 42-37 16-21 36x16 6x26 37x17 21x12 41-36 1-6 36x16 6x26 46-41 12-17 41-37 17-22 37-32 22x42 48x37 8-12 50-45 3-9 44-39 10-14 24-20 15x24 25x23 14-20 49-44 12-17 44-40 17-22 23x21 26x17 39-34 17-21 37-31 21x41 47x36 4-10 34-30 9-13 30-25 7-12 25x14 10x19 35-30 19-23 36-31 12-17 30-25 23-29 40-34 29x40 45x34 5-10 34-29 10-14 29-24 14x34 25-20 34-40 20-15 40-45 15-10 13-19 10-5 19-24 5-32 24-29 32-49 29-33 49-43 17-21 43x26 45-50 31-27 50-39 26-48 39-50 48-37 50-44 37-19 44-49'.split() 20 | game = play_game(frisian_moves, 'frisian') 21 | assert list(map(lambda move: move.board_move, game.legal_moves())) == [[[27, 21]], [[27, 22]]] 22 | # Frysk! 23 | frysk_moves = '48-43 1-7 43-39 7-11 46-41 11-17 41-37 17-21 37-31 21x41 47x36 5-10 36-31 3-8 49-44 10-14 44-40 8-12 40-35 14-20 39-33 20-24 50-45 4-9 33-28 2-8 28-22 12x32 31x33 8-12 35-30 24x35 45x25 9-14 33-28 12-17 28-23 17-21 23-18 21-27 18-12 27-31 12-7 31-37 7-1 37-42 1-18 42-47 18-4 47-36 4x34 36-41 25-20 41-10 20-15 10-19 34-12 19-28 12-34 28-19 15-10 19x5 34-18 5-14 18-22 14-10'.split() 24 | play_game(frysk_moves, 'frysk') 25 | # Antidraughts 26 | antidraughts_moves = '35-30 16-21 31-27 20-24 27x16 24x35 36-31 15-20 31-26 20-25 41-36 17-21 16x27 10-15 33-28 18-22 27x18 13x33 38x29 12-18 29-24 19x30 32-27 18-22 27x18 5-10 43-38 8-12 18-13 9x18 36-31 18-22 49-43 22-27 31x22 12-18 22x13 14-19 13x24 30x19 34-29 11-17 26-21 17x26 29-24 19x30 37-32 6-11 46-41 7-12 38-33 30-34 40x29 12-17 45-40 2-8 50-45 15-20 41-36 1-7 36-31 26x28 33x22 17x28 39-33 28x50 42-37 35x44 37-31 8-12 29-24 20x29 45-40 44x35 43-39 50x36 47-41 36x47 48-42 47x33'.split() 27 | game = play_game(antidraughts_moves, 'antidraughts') 28 | assert game.winner() == draughts.WHITE 29 | # Breakthrough 30 | breakthrough_moves = '32-28 19-24 37-32 17-21 34-29 21-26 39-34 26x37 42x31 18-22 28x17 11x22 44-39 12-18 47-42 14-19 50-44 10-14 41-37 7-12 34-30 4-10 46-41 1-7 30-25 19-23 40-34 14-19 25x14 9x20 32-28 23x32 37x17 12x21 34-30 21-26 41-37 20-25 29x20 25x34 39x30 15x24 45-40 16-21 38-32 6-11 43-38 11-16 49-43 7-12 40-34 3-9 43-39 10-14 33-29 24x33 38x29 2-7 30-24 19x30 35x24 5-10 42-38 18-22 34-30 21-27 32x21 16x27 29-23 10-15 30-25 27-32 38x18 13x22 48-42 22-28 23x32 8-13 39-33 14-19 33-28 19x30 25x34 7-11 31-27 9-14 34-29 14-20 29-23 20-25 27-22 12-17 22-18 13x33 23-19 26-31 37x26 33-39 44x33 17-22 19-13 25-30 13-9 22-27 32x21 11-17 21x12 15-20 9-4'.split() 31 | play_game(breakthrough_moves, 'breakthrough') 32 | # Russian 33 | russian_moves = 'a3-b4 b6-a5 g3-f4 d6-e5 f4xd6 e7xa3 e3-d4 c7-d6 c3-b4 a5xe5 h2-g3 f6-g5 g3-h4 g5-f4 f2-g3 f4xh2 d2-c3 a7-b6 c3-b4 a3xc5 g1-f2 h6-g5 h4xd4 c5xg1 e1-f2 g1xe3 c1-d2 e3xa3 a1-b2 a3xc1'.split() 34 | play_game(russian_moves, 'russian') 35 | # Brazilian 36 | brazilian_moves = 'g3-f4 d6-e5 f4xd6 c7xe5 a3-b4 e7-d6 b4-a5 b8-c7 c3-d4 e5xc3 b2xd4 b6-c5 d4xb6 a7xc5 h2-g3 f6-e5 g3-f4 e5xg3 f2xh4 h6-g5 h4xf6 g7xe5 a5-b6 c7xa5 c1-b2 d8-e7 b2-a3 f8-g7 a3-b4 a5xc3 d2xb4 c5xa3 g1-f2 e5-d4 e3xc5 d6xb4 f2-g3 g7-f6 e1-d2 f6-e5 a1-b2 a3xe3 g3-f4'.split() 37 | play_game(brazilian_moves, 'brazilian') 38 | # English/American 39 | english_moves = '11-15 23-18 8-11 27-23 4-8 23-19 10-14 19x10 14x23 26x19 7x14 24-20 6-10 22-17 9-13 30-26 13x22 25x9 5x14 29-25 14-18 26-23 18x27 32x23 11-15 28-24 2-6 21-17 8-11 25-22 6-9 23-18 1-5 17-13 9-14 18x9 5x14 13-9 14-18 22-17 18-23 9-6 15-18 6-2 18-22 2-6 3-8 6x15 11x18 19-15 22-26 31x22 18x25 24-19 23-27 20-16 27-31 16-11 31-27 11x4 27-23'.split() 40 | play_game(english_moves, 'american') 41 | # Italian 42 | italian_moves = '21-17 12-15 23-19 11-14 19x12 8x15 28-23 14-18 22x13 9x18 23-20 10-14 20x11 6x15 17-13 3-6 13-9 5-10 32-28 1-5 26-21 7-12 21-17 12-16 30-26 6-11 26-21 4-7 29-26 14-19 21x14 11x18 26-21 16-20 21x14 19-22 27x18 10x19 31-27 19-22 28-23 22x31 23x16 31-27 18-14 27-23 25-21 23-20 17-13 15-19 21-18 19-22 13-10 22-26 10x1 26-30 1-5 30-27 5-10 27-22 10-13 22-19 14-10 19-15 10-5 20-23 5-1 23-20 1-5 7-11 5-10 15-12 10-6 11-15 6-11'.split() 43 | play_game(italian_moves, 'italian') 44 | # Turkish 45 | turkish_moves = ['e3-e4', 'f6-f5', 'b3-b4', 'f5-g5', 'f3-f4', 'a6-a5', 'a3-a4', 'a5xa1', 'b4-a4', 'a1xa6', 'e4-e5', 'e6xg4', 'c3-b3', 'a6-a3', 'd3-d4', 'a3xe1', 'd2-d3', 'e1-f1', 'd4-e4'] 46 | play_game(turkish_moves, 'turkish') 47 | --------------------------------------------------------------------------------