├── run.bat ├── .gitignore ├── match_chesscom.gif ├── match_lichess.gif ├── src ├── assets │ └── pawn_32x32.png ├── utilities.py ├── grabbers │ ├── grabber.py │ ├── chesscom_grabber.py │ └── lichess_grabber.py ├── overlay.py ├── stockfish_bot.py └── gui.py ├── requirements.txt ├── LICENSE └── README.md /run.bat: -------------------------------------------------------------------------------- 1 | venv\Scripts\python.exe src\gui.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | stockfish 3 | __pycache__ 4 | venv -------------------------------------------------------------------------------- /match_chesscom.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PanagiotisIatrou/PawnBit/HEAD/match_chesscom.gif -------------------------------------------------------------------------------- /match_lichess.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PanagiotisIatrou/PawnBit/HEAD/match_lichess.gif -------------------------------------------------------------------------------- /src/assets/pawn_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PanagiotisIatrou/PawnBit/HEAD/src/assets/pawn_32x32.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | multiprocess==0.70.14 2 | selenium==4.9.1 3 | webdriver-manager==4.0.1 4 | PyAutoGUI==0.9.53 5 | chess==1.10.0 6 | stockfish==3.28.0 7 | packaging==24.0 8 | keyboard==0.13.5 9 | PyQt6==6.9.0 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Panagiotis Iatrou 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 | -------------------------------------------------------------------------------- /src/utilities.py: -------------------------------------------------------------------------------- 1 | from selenium.webdriver.remote.webdriver import WebDriver 2 | from selenium import webdriver 3 | 4 | 5 | # Converts a chess character into an int 6 | # Examples: a -> 1, b -> 2, h -> 8, etc. 7 | def char_to_num(char): 8 | return ord(char) - ord("a") + 1 9 | 10 | 11 | # Attaches to a running webdriver 12 | # Returns the webdriver 13 | # Taken from https://stackoverflow.com/a/48194907/5868441 14 | def attach_to_session(executor_url, session_id): 15 | original_execute = WebDriver.execute 16 | 17 | def new_command_execute(self, command, params=None): 18 | if command == "newSession": 19 | # Mock the response 20 | return {'success': 0, 'value': None, 'sessionId': session_id} 21 | else: 22 | return original_execute(self, command, params) 23 | 24 | # Patch the function before creating the driver object 25 | WebDriver.execute = new_command_execute 26 | driver = webdriver.Remote(command_executor=executor_url, desired_capabilities={}) 27 | driver.session_id = session_id 28 | 29 | # Replace the patched function with original function 30 | WebDriver.execute = original_execute 31 | 32 | return driver 33 | -------------------------------------------------------------------------------- /src/grabbers/grabber.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from utilities import attach_to_session 4 | 5 | 6 | # Base abstract class for different chess sites 7 | class Grabber(ABC): 8 | def __init__(self, chrome_url, chrome_session_id): 9 | self.chrome = attach_to_session(chrome_url, chrome_session_id) 10 | self._board_elem = None 11 | self.moves_list = {} 12 | 13 | def get_board(self): 14 | return self._board_elem 15 | 16 | # Resets the moves list when changing games 17 | def reset_moves_list(self): 18 | """Reset the moves list when a new game starts""" 19 | self.moves_list = {} 20 | 21 | # Returns the coordinates of the top left corner of the ChromeDriver 22 | def get_top_left_corner(self): 23 | canvas_x_offset = self.chrome.execute_script("return window.screenX + (window.outerWidth - window.innerWidth) / 2 - window.scrollX;") 24 | canvas_y_offset = self.chrome.execute_script("return window.screenY + (window.outerHeight - window.innerHeight) - window.scrollY;") 25 | return canvas_x_offset, canvas_y_offset 26 | 27 | # Sets the _board_elem variable 28 | @abstractmethod 29 | def update_board_elem(self): 30 | pass 31 | 32 | # Returns True if white, False if black, 33 | # None if the color is not found 34 | @abstractmethod 35 | def is_white(self): 36 | pass 37 | 38 | # Checks if the game over window popup is open 39 | # Returns True if it is, False if it isn't 40 | @abstractmethod 41 | def is_game_over(self): 42 | pass 43 | 44 | # Returns the current board move list 45 | # Ex. ["e4", "c5", "Nf3"] 46 | @abstractmethod 47 | def get_move_list(self): 48 | pass 49 | 50 | # Returns True if the player does puzzles 51 | # and False if not 52 | @abstractmethod 53 | def is_game_puzzles(self): 54 | pass 55 | 56 | # Clicks the next button on the puzzles page 57 | @abstractmethod 58 | def click_puzzle_next(self): 59 | pass 60 | 61 | # Makes a mouseless move 62 | @abstractmethod 63 | def make_mouseless_move(self, move, move_count): 64 | pass 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PawnBit 2 | 3 | 4 | 5 | 6 | A bot for chess.com and lichess.org that automatically plays chess for you 7 | 8 | Chess.com 9 | ![](match_chesscom.gif) 10 | 11 | Lichess.org 12 | ![](match_lichess.gif) 13 | Note that the mouse is being moved by python 14 | 15 | ## How to install 16 | 1) Clone the repository or just download the repository as a .zip 17 | 2) Download Stockfish from https://stockfishchess.org/ 18 | **Note** that on Linux systems you must manually add execute permissions to the stockfish executable 19 | 3) Open a Terminal 20 | 4) `cd PawnBit` 21 | 5) Windows: `python -m venv venv` 22 | Linux: `python3 -m venv venv` 23 | 6) Windows: `venv\Scripts\pip.exe install -r requirements.txt` 24 | Linux: `venv/bin/pip3 install -r requirements.txt` 25 | 26 | ## How to use 27 | 1) Open a Terminal 28 | 2) `cd PawnBit` 29 | 3) Windows: `venv\Scripts\python.exe src\gui.py` 30 | Linux: `venv/bin/python3 src/gui.py` 31 | 4) Click Select Stockfish on the GUI that opens. This will open a file explorer. Navigate to the folder where you downloaded Stockfish and select the Stockfish executable. 32 | 5) Click Open Browser. This will open ChromeDriver and load the selected chess website. 33 | 6) Navigate to a live match (online or vs bot) 34 | 7) Click Start (or press 1) 35 | 8) Enjoy 36 | 37 | **Note** You can stop the bot at any time by pressing Stop or pressing 2. 38 | 39 | ## Currently supports 40 | - Windows/Linux platforms 41 | - Chess.com 42 | - Lichess.org 43 | - Playing vs humans 44 | - Playing vs bots 45 | - Puzzles (with option for non-stop solving): 46 | - [ ] chess.com 47 | - [x] lichess.org 48 | - Manual mode (Press or hold 3 to move when enabled) 49 | An arrow with the best move is also displayed 50 | - Mouseless mode (The moves are made without the mouse moving, also works while the browser is at the background): 51 | - [ ] chess.com 52 | - [x] lichess.org 53 | - Bongcloud mode ( ͡° ͜ʖ ͡° ) 54 | - Non-stop mode (The bot will play non-stop) 55 | - [ ] chess.com 56 | - [x] lichess.org 57 | - Ability to add a fixed amount of mouse latency 58 | - Skill level selection (0-20) 59 | - Depth level selection (1-20) 60 | - Memory (RAM) usage selection 61 | - CPU threads number selection 62 | - Slow Mover option (defaults to 100, 10 ≤ Slow Mover ≤ 1000) 63 | lower values will make Stockfish take less time in games, higher values will make it think longer 64 | - Exporting finished games to PGN 65 | - W/D/L, accuracy and material statistics tracking 66 | - Evaluation bar display on the side 67 | 68 | ## Future features 69 | - Random delays in between moves to simulate human behavior 70 | 71 | ## Disclaimer 72 | Under no circumstances should you use this bot to cheat in online games or tournaments. This bot was made for educational purposes only. 73 | Using this bot to cheat in online games or tournaments is against the rules of chess.com and will result in a ban. 74 | -------------------------------------------------------------------------------- /src/grabbers/chesscom_grabber.py: -------------------------------------------------------------------------------- 1 | from selenium.common import NoSuchElementException 2 | from selenium.webdriver.common.by import By 3 | 4 | from grabbers.grabber import Grabber 5 | 6 | 7 | class ChesscomGrabber(Grabber): 8 | def __init__(self, chrome_url, chrome_session_id): 9 | super().__init__(chrome_url, chrome_session_id) 10 | # The moves_list is now initialized in the base class 11 | 12 | def update_board_elem(self): 13 | try: 14 | self._board_elem = self.chrome.find_element(By.XPATH, "//*[@id='board-play-computer']") 15 | except NoSuchElementException: 16 | try: 17 | self._board_elem = self.chrome.find_element(By.XPATH, "//*[@id='board-single']") 18 | except NoSuchElementException: 19 | self._board_elem = None 20 | 21 | def is_white(self): 22 | # Find the square names list 23 | square_names = None 24 | try: 25 | coordinates = self.chrome.find_element(By.XPATH, "//*[@id='board-play-computer']//*[name()='svg']") 26 | square_names = coordinates.find_elements(By.XPATH, ".//*") 27 | except NoSuchElementException: 28 | try: 29 | coordinates = self.chrome.find_elements(By.XPATH, "//*[@id='board-single']//*[name()='svg']") 30 | coordinates = [x for x in coordinates if x.get_attribute("class") == "coordinates"][0] 31 | square_names = coordinates.find_elements(By.XPATH, ".//*") 32 | except NoSuchElementException: 33 | return None 34 | 35 | # Find the square with the smallest x and biggest y values (bottom left number) 36 | elem = None 37 | min_x = None 38 | max_y = None 39 | for i in range(len(square_names)): 40 | name_element = square_names[i] 41 | x = float(name_element.get_attribute("x")) 42 | y = float(name_element.get_attribute("y")) 43 | 44 | if i == 0 or (x <= min_x and y >= max_y): 45 | min_x = x 46 | max_y = y 47 | elem = name_element 48 | 49 | # Use this square to determine whether the player is white or black 50 | num = elem.text 51 | return num == "1" 52 | 53 | def is_game_over(self): 54 | try: 55 | # Find the game over window 56 | game_over_window = self.chrome.find_element(By.CLASS_NAME, "board-modal-container") 57 | return game_over_window is not None 58 | except NoSuchElementException: 59 | # Return False since the game over window is not found 60 | return False 61 | 62 | def reset_moves_list(self): 63 | """Reset the moves list when a new game starts""" 64 | self.moves_list = {} 65 | 66 | def get_move_list(self): 67 | # Find the moves list 68 | try: 69 | move_list_elem = self.chrome.find_element(By.CLASS_NAME, "play-controller-scrollable") 70 | except NoSuchElementException: 71 | try: 72 | move_list_elem = self.chrome.find_element(By.CLASS_NAME, "mode-swap-move-list-wrapper-component") 73 | except NoSuchElementException: 74 | return None 75 | 76 | # Check if we're in a new game by looking at the number of moves 77 | # If there are no visible moves but we have moves in our list, we're in a new game 78 | visible_moves = move_list_elem.find_elements(By.CSS_SELECTOR, "div.node[data-node]") 79 | if len(visible_moves) == 0 and self.moves_list: 80 | # Reset moves list for new game 81 | self.reset_moves_list() 82 | 83 | # Select all children with class containing "white node" or "black node" 84 | # Moves that are not pawn moves have a different structure 85 | # containing children 86 | if not self.moves_list: 87 | # If the moves list is empty, find all moves 88 | moves = move_list_elem.find_elements(By.CSS_SELECTOR, "div.node[data-node]") 89 | else: 90 | # If the moves list is not empty, find only the new moves 91 | moves = move_list_elem.find_elements(By.CSS_SELECTOR, "div.node[data-node]:not([data-processed])") 92 | 93 | for move in moves: 94 | move_class = move.get_attribute("class") 95 | 96 | # Check if it is indeed a move 97 | if "white-move" in move_class or "black-move" in move_class: 98 | # Check if it has a figure - search deeper in the structure 99 | try: 100 | # Look for any element with data-figurine attribute anywhere within this move 101 | figurine_elem = move.find_element(By.CSS_SELECTOR, "[data-figurine]") 102 | figure = figurine_elem.get_attribute("data-figurine") 103 | except NoSuchElementException: 104 | figure = None 105 | 106 | # Check if it was en-passant or figure-move 107 | if figure is None: 108 | # If the moves_list is empty or the last move was not the current move 109 | self.moves_list[move.get_attribute("data-node")] = move.text 110 | elif "=" in move.text: 111 | m = move.text + figure 112 | # If the move is a check, add the + in the end 113 | if "+" in m: 114 | m = m.replace("+", "") 115 | m += "+" 116 | 117 | # If the moves_list is empty or the last move was not the current move 118 | self.moves_list[move.get_attribute("data-node")] = m 119 | else: 120 | # If the moves_list is empty or the last move was not the current move 121 | self.moves_list[move.get_attribute("data-node")] = figure + move.text 122 | 123 | # Mark the move as processed 124 | self.chrome.execute_script("arguments[0].setAttribute('data-processed', 'true')", move) 125 | 126 | return list(self.moves_list.values()) 127 | 128 | def is_game_puzzles(self): 129 | return False 130 | 131 | def click_puzzle_next(self): 132 | pass 133 | 134 | def click_game_next(self): 135 | pass 136 | 137 | def make_mouseless_move(self, move, move_count): 138 | pass 139 | -------------------------------------------------------------------------------- /src/grabbers/lichess_grabber.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from selenium.common import NoSuchElementException, StaleElementReferenceException 4 | from selenium.webdriver.common.by import By 5 | 6 | from grabbers.grabber import Grabber 7 | 8 | 9 | class LichessGrabber(Grabber): 10 | def __init__(self, chrome_url, chrome_session_id): 11 | super().__init__(chrome_url, chrome_session_id) 12 | self.tag_name = None 13 | 14 | def update_board_elem(self): 15 | # Keep looking for board 16 | while True: 17 | try: 18 | # Try finding the normal board 19 | self._board_elem = self.chrome.find_element(By.XPATH, 20 | '//*[@id="main-wrap"]/main/div[1]/div[1]/div/cg-container') 21 | return 22 | except NoSuchElementException: 23 | try: 24 | # Try finding the board in the puzzles page 25 | self._board_elem = self.chrome.find_element(By.XPATH, '/html/body/div[2]/main/div[1]/div/cg-container') 26 | return 27 | except NoSuchElementException: 28 | self._board_elem = None 29 | 30 | def is_white(self): 31 | # sourcery skip: assign-if-exp, boolean-if-exp-identity, remove-unnecessary-cast 32 | # Get "ranks" child 33 | children = self._board_elem.find_elements(By.XPATH, "./*") 34 | child = [x for x in children if "ranks" in x.get_attribute("class")][0] 35 | if child.get_attribute("class") == "ranks": 36 | return True 37 | else: 38 | return False 39 | 40 | def is_game_over(self): 41 | # sourcery skip: assign-if-exp, boolean-if-exp-identity, reintroduce-else, remove-unnecessary-cast 42 | try: 43 | # Find the game over window 44 | self.chrome.find_element(By.XPATH, '//*[@id="main-wrap"]/main/aside/div/section[2]') 45 | 46 | # If we don't have an exception at this point, we have found the game over window 47 | return True 48 | except NoSuchElementException: 49 | # Try finding the puzzles game over window and checking its class 50 | try: 51 | # The game over window 52 | game_over_window = self.chrome.find_element(By.XPATH, '/html/body/div[2]/main/div[2]/div[3]/div[1]') 53 | 54 | if game_over_window.get_attribute("class") == "complete": 55 | return True 56 | 57 | # If we don't have an exception at this point and the window's class is not "complete", 58 | # then the game is still going 59 | return False 60 | except NoSuchElementException: 61 | return False 62 | 63 | def set_moves_tag_name(self): 64 | if self.is_game_puzzles(): 65 | return False 66 | 67 | move_list_elem = self.get_normal_move_list_elem() 68 | 69 | if move_list_elem is None or move_list_elem == []: 70 | return False 71 | 72 | try: 73 | last_child = move_list_elem.find_element(By.XPATH, "*[last()]") 74 | self.tag_name = last_child.tag_name 75 | 76 | return True 77 | except NoSuchElementException: 78 | return False 79 | 80 | def get_move_list(self): 81 | # sourcery skip: assign-if-exp, merge-else-if-into-elif, use-fstring-for-concatenation 82 | is_puzzles = self.is_game_puzzles() 83 | 84 | # Find the move list element 85 | if is_puzzles: 86 | move_list_elem = self.get_puzzles_move_list_elem() 87 | 88 | if move_list_elem is None: 89 | return None 90 | else: 91 | move_list_elem = self.get_normal_move_list_elem() 92 | 93 | if move_list_elem is None: 94 | return None 95 | if (not move_list_elem) or (self.tag_name is None and self.set_moves_tag_name() is False): 96 | return [] 97 | 98 | # Get the move elements (children of the move list element) 99 | try: 100 | if not is_puzzles: 101 | if not self.moves_list: 102 | # If the moves list is empty, find all moves 103 | children = move_list_elem.find_elements(By.CSS_SELECTOR, self.tag_name) 104 | else: 105 | # If the moves list is not empty, find only the new moves 106 | children = move_list_elem.find_elements(By.CSS_SELECTOR, self.tag_name + ":not([data-processed])") 107 | else: 108 | if not self.moves_list: 109 | # If the moves list is empty, find all moves 110 | children = move_list_elem.find_elements(By.CSS_SELECTOR, "move") 111 | else: 112 | # If the moves list is not empty, find only the new moves 113 | children = move_list_elem.find_elements(By.CSS_SELECTOR, "move:not([data-processed])") 114 | except NoSuchElementException: 115 | return None 116 | 117 | # Get the moves from the elements 118 | for move_element in children: 119 | # Sanitize the move 120 | move = re.sub(r"[^a-zA-Z0-9+-]", "", move_element.text) 121 | if move != "": 122 | self.moves_list[move_element.id] = move 123 | 124 | # Mark the move as processed 125 | self.chrome.execute_script("arguments[0].setAttribute('data-processed', 'true')", move_element) 126 | 127 | return [val for val in self.moves_list.values()] 128 | 129 | def get_puzzles_move_list_elem(self): 130 | try: 131 | # Try finding the move list in the puzzles page 132 | move_list_elem = self.chrome.find_element(By.XPATH, '/html/body/div[2]/main/div[2]/div[2]/div') 133 | 134 | return move_list_elem 135 | except NoSuchElementException: 136 | return None 137 | 138 | def get_normal_move_list_elem(self): 139 | try: 140 | # Try finding the normal move list 141 | move_list_elem = self.chrome.find_element(By.XPATH, '//*[@id="main-wrap"]/main/div[1]/rm6/l4x') 142 | 143 | return move_list_elem 144 | except NoSuchElementException: 145 | try: 146 | # Try finding the normal move list when there are no moves yet 147 | self.chrome.find_element(By.XPATH, '//*[@id="main-wrap"]/main/div[1]/rm6') 148 | 149 | # If we don't have an exception at this point, we don't have any moves yet 150 | return [] 151 | except NoSuchElementException: 152 | return None 153 | 154 | def is_game_puzzles(self): 155 | try: 156 | # Try finding the puzzles text 157 | self.chrome.find_element(By.XPATH, "/html/body/div[2]/main/aside/div[1]/div[1]/div/p[1]") 158 | 159 | # If we don't have an exception at this point, the game is a puzzle 160 | return True 161 | except NoSuchElementException: 162 | return False 163 | 164 | def click_puzzle_next(self): 165 | # Find the next continue training button 166 | try: 167 | next_button = self.chrome.find_element(By.XPATH, "/html/body/div[2]/main/div[2]/div[3]/a") 168 | except NoSuchElementException: 169 | try: 170 | next_button = self.chrome.find_element(By.XPATH, '//*[@id="main-wrap"]/main/div[2]/div[3]/div[3]/a[2]') 171 | except NoSuchElementException: 172 | return 173 | 174 | # Click the continue training button 175 | self.chrome.execute_script("arguments[0].click();", next_button) 176 | 177 | def click_game_next(self): 178 | # Find the next new game button 179 | try: 180 | next_button = self.chrome.find_element(By.XPATH, "//*[contains(text(), 'New opponent')]") 181 | except NoSuchElementException: 182 | return 183 | except StaleElementReferenceException: 184 | return 185 | 186 | # Click the next game button 187 | self.chrome.execute_script("arguments[0].click();", next_button) 188 | 189 | def make_mouseless_move(self, move, move_count): 190 | message = '{"t":"move","d":{"u":"' + move + '","b":1,"a":' + str(move_count) + '}}' 191 | script = 'lichess.socket.ws.send(JSON.stringify(' + message + '))' 192 | self.chrome.execute_script(script) 193 | -------------------------------------------------------------------------------- /src/overlay.py: -------------------------------------------------------------------------------- 1 | import math 2 | import sys 3 | import threading 4 | from PyQt6.QtCore import Qt, QPoint, QRect 5 | from PyQt6.QtGui import QBrush, QColor, QPainter, QPen, QGuiApplication, QPolygon, QFont 6 | from PyQt6.QtWidgets import QApplication, QWidget 7 | 8 | 9 | class OverlayScreen(QWidget): 10 | def __init__(self, stockfish_queue): 11 | super().__init__() 12 | self.stockfish_queue = stockfish_queue 13 | 14 | # Set the window to be the size of the screen 15 | self.screen = QGuiApplication.screens()[0] 16 | self.setFixedWidth(self.screen.size().width()) 17 | self.setFixedHeight(self.screen.size().height()) 18 | 19 | # Set the window to be transparent 20 | self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) 21 | self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) 22 | self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint) 23 | 24 | # A list of QPolygon objects containing the points of the arrows 25 | self.arrows = [] 26 | 27 | # Evaluation bar properties 28 | self.eval_bar_visible = False 29 | self.eval_value = 0.0 30 | self.eval_type = "cp" # "cp" for centipawns or "mate" for mate 31 | self.eval_text = "0.00" 32 | self.is_white = True # Default assumption 33 | 34 | # Board position, will be updated 35 | self.board_position = None 36 | 37 | # Evaluation bar dimensions 38 | self.eval_bar_width = 40 39 | self.eval_bar_height = 400 40 | self.eval_bar_x = 20 # Default x position 41 | self.eval_bar_y = (self.height() - self.eval_bar_height) // 2 # Default y position 42 | self.eval_bar_margin = 15 # Margin between board and eval bar 43 | 44 | # Start the message queue thread 45 | self.message_queue_thread = threading.Thread(target=self.message_queue_thread) 46 | self.message_queue_thread.start() 47 | 48 | def message_queue_thread(self): 49 | """ 50 | This thread is used to receive messages from the stockfish message queue 51 | and update the arrows 52 | Args: 53 | None 54 | Returns: 55 | None 56 | """ 57 | 58 | while True: 59 | message = self.stockfish_queue.get() 60 | if isinstance(message, list): 61 | # Arrow data 62 | self.set_arrows(message) 63 | elif isinstance(message, dict) and "eval" in message: 64 | # Evaluation data 65 | eval_value = message["eval"] 66 | eval_type = message.get("eval_type", "cp") 67 | 68 | # Update board position if provided 69 | if "board_position" in message: 70 | self.board_position = message["board_position"] 71 | self.update_eval_bar_position() 72 | 73 | # Update bot color if provided 74 | if "is_white" in message: 75 | self.is_white = message["is_white"] 76 | 77 | self.update_eval_bar(eval_value, eval_type) 78 | 79 | def update_eval_bar_position(self): 80 | """ 81 | Update the evaluation bar position based on the chess board position 82 | """ 83 | if not self.board_position: 84 | return 85 | 86 | # Position the eval bar to the left of the board with a small margin 87 | self.eval_bar_x = self.board_position['x'] - self.eval_bar_width - self.eval_bar_margin 88 | 89 | # Make the eval bar the exact same height as the board 90 | self.eval_bar_height = self.board_position['height'] 91 | self.eval_bar_y = self.board_position['y'] 92 | 93 | def update_eval_bar(self, eval_value, eval_type="cp"): 94 | """ 95 | Update the evaluation bar with a new value 96 | Args: 97 | eval_value: The evaluation value (float for centipawns, int for mate) 98 | eval_type: "cp" for centipawns or "mate" for mate 99 | """ 100 | self.eval_bar_visible = True 101 | self.eval_type = eval_type 102 | self.eval_value = eval_value 103 | 104 | # Format text display 105 | if eval_type == "cp": 106 | self.eval_text = f"{eval_value:.2f}" 107 | else: # mate 108 | self.eval_text = f"M{eval_value}" 109 | 110 | self.update() 111 | 112 | def set_arrows(self, arrows): 113 | """ 114 | This function is used to set the arrows to be drawn on the screen 115 | Args: 116 | arrows: A list of tuples containing the start and end position of the arrows 117 | in the form of ((start_point, end_point), (start_point, end_point)) 118 | Returns: 119 | None 120 | """ 121 | 122 | self.arrows = [] 123 | for arrow in arrows: 124 | poly = self.get_arrow_polygon( 125 | QPoint(arrow[0][0], arrow[0][1]), 126 | QPoint(arrow[1][0], arrow[1][1]) 127 | ) 128 | self.arrows.append(poly) 129 | self.update() 130 | 131 | def paintEvent(self, event): 132 | super().paintEvent(event) 133 | painter = QPainter(self) 134 | 135 | # Draw arrows 136 | painter.setPen(QPen(Qt.GlobalColor.red, 1, Qt.PenStyle.NoPen)) 137 | painter.setBrush(QBrush(QColor(255, 0, 0, 122), Qt.BrushStyle.SolidPattern)) 138 | for arrow in self.arrows: 139 | painter.drawPolygon(arrow) 140 | 141 | # Draw evaluation bar if visible 142 | if self.eval_bar_visible: 143 | self.draw_eval_bar(painter) 144 | 145 | painter.end() 146 | 147 | def draw_eval_bar(self, painter): 148 | """ 149 | Draw the evaluation bar on the screen 150 | Args: 151 | painter: QPainter object 152 | """ 153 | # Bar border 154 | border_rect = QRect( 155 | self.eval_bar_x - 2, 156 | self.eval_bar_y - 2, 157 | self.eval_bar_width + 4, 158 | self.eval_bar_height + 4 159 | ) 160 | painter.setPen(QPen(QColor(40, 40, 40), 2)) 161 | painter.setBrush(QBrush(QColor(40, 40, 40, 180))) 162 | painter.drawRect(border_rect) 163 | 164 | # The eval value is already from the player's perspective 165 | # (positive = advantage for the player, negative = advantage for opponent) 166 | if self.eval_type == "cp": 167 | value = max(min(float(self.eval_value), 10.0), -10.0) 168 | player_advantage = 1.0 / (1.0 + math.exp(-value * 0.5)) # Sigmoid function 169 | else: # mate 170 | mate_value = int(self.eval_value) 171 | if mate_value > 0: # Mate for player 172 | player_advantage = 1.0 # Maximum advantage - full bar 173 | else: # Mate for opponent 174 | player_advantage = 0.0 # Minimum advantage - no bar 175 | 176 | # Set colors based on player's color 177 | if self.is_white: 178 | bottom_color = QColor(235, 235, 235, 220) # White 179 | top_color = QColor(30, 30, 30, 220) # Black 180 | else: 181 | bottom_color = QColor(30, 30, 30, 220) # Black 182 | top_color = QColor(235, 235, 235, 220) # White 183 | 184 | # Calculate section heights 185 | player_height = int(self.eval_bar_height * player_advantage) 186 | opponent_height = self.eval_bar_height - player_height 187 | 188 | # Draw opponent's section (top) 189 | opponent_rect = QRect( 190 | self.eval_bar_x, 191 | self.eval_bar_y, 192 | self.eval_bar_width, 193 | opponent_height 194 | ) 195 | painter.setPen(Qt.PenStyle.NoPen) 196 | painter.setBrush(QBrush(top_color)) 197 | painter.drawRect(opponent_rect) 198 | 199 | # Draw player's section (bottom) 200 | player_rect = QRect( 201 | self.eval_bar_x, 202 | self.eval_bar_y + opponent_height, 203 | self.eval_bar_width, 204 | player_height 205 | ) 206 | painter.setBrush(QBrush(bottom_color)) 207 | painter.drawRect(player_rect) 208 | 209 | # Draw the center line 210 | center_y = self.eval_bar_y + (self.eval_bar_height // 2) 211 | painter.setPen(QPen(QColor(100, 100, 100, 150), 1)) 212 | painter.drawLine( 213 | self.eval_bar_x, 214 | center_y, 215 | self.eval_bar_x + self.eval_bar_width, 216 | center_y 217 | ) 218 | 219 | # Draw evaluation text 220 | painter.setFont(QFont("Arial", 11, QFont.Weight.Bold)) 221 | 222 | # Format the display text 223 | display_text = self.eval_text 224 | if self.eval_type == "cp" and float(self.eval_value) > 0: 225 | display_text = "+" + display_text 226 | 227 | # Position text at the bottom 228 | text_rect = QRect( 229 | self.eval_bar_x, 230 | self.eval_bar_y + self.eval_bar_height - 20, 231 | self.eval_bar_width, 232 | 20 233 | ) 234 | 235 | # Draw text background 236 | painter.setBrush(QBrush(QColor(60, 60, 60, 180))) 237 | painter.setPen(Qt.PenStyle.NoPen) 238 | painter.drawRect(text_rect) 239 | 240 | # Draw text 241 | painter.setPen(QPen(QColor(255, 255, 255))) 242 | painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, display_text) 243 | 244 | def get_arrow_polygon(self, start_point, end_point): 245 | """ 246 | This function is used to get the polygon for the arrow 247 | Args: 248 | start_point: The start point of the arrow 249 | end_point: The end point of the arrow 250 | Returns: 251 | A QPolygon object containing the points of the arrow 252 | """ 253 | 254 | try: 255 | dx, dy = start_point.x() - end_point.x(), start_point.y() - end_point.y() 256 | 257 | # Normalize the vector 258 | leng = math.sqrt(dx ** 2 + dy ** 2) 259 | norm_x, norm_y = dx / leng, dy / leng 260 | 261 | # Get the perpendicular vector 262 | perp_x = -norm_y 263 | perp_y = norm_x 264 | 265 | arrow_height = 25 266 | left_x = end_point.x() + arrow_height * norm_x * 1.5 + arrow_height * perp_x 267 | left_y = end_point.y() + arrow_height * norm_y * 1.5 + arrow_height * perp_y 268 | 269 | right_x = end_point.x() + arrow_height * norm_x * 1.5 - arrow_height * perp_x 270 | right_y = end_point.y() + arrow_height * norm_y * 1.5 - arrow_height * perp_y 271 | 272 | point2 = QPoint(int(left_x), int(left_y)) 273 | point3 = QPoint(int(right_x), int(right_y)) 274 | 275 | mid_point1 = QPoint(int((2 / 5) * point2.x() + (3 / 5) * point3.x()), int((2 / 5) * point2.y() + (3 / 5) * point3.y())) 276 | mid_point2 = QPoint(int((3 / 5) * point2.x() + (2 / 5) * point3.x()), int((3 / 5) * point2.y() + (2 / 5) * point3.y())) 277 | 278 | start_left = QPoint(int(start_point.x() + (arrow_height / 5) * perp_x), int(start_point.y() + (arrow_height / 5) * perp_y)) 279 | start_right = QPoint(int(start_point.x() - (arrow_height / 5) * perp_x), int(start_point.y() - (arrow_height / 5) * perp_y)) 280 | 281 | return QPolygon([end_point, point2, mid_point1, start_right, start_left, mid_point2, point3]) 282 | except Exception as e: 283 | print(e) 284 | 285 | 286 | def run(stockfish_queue): 287 | """ 288 | This function is used to run the overlay 289 | Args: 290 | stockfish_queue: The message queue used to communicate with the stockfish thread 291 | Returns: 292 | None 293 | """ 294 | 295 | app = QApplication(sys.argv) 296 | overlay = OverlayScreen(stockfish_queue) 297 | overlay.show() 298 | app.exec() 299 | -------------------------------------------------------------------------------- /src/stockfish_bot.py: -------------------------------------------------------------------------------- 1 | import multiprocess 2 | from stockfish import Stockfish 3 | import pyautogui 4 | import time 5 | import sys 6 | import os 7 | import chess 8 | import re 9 | from grabbers.chesscom_grabber import ChesscomGrabber 10 | from grabbers.lichess_grabber import LichessGrabber 11 | from utilities import char_to_num 12 | import keyboard 13 | 14 | 15 | class StockfishBot(multiprocess.Process): 16 | def __init__(self, chrome_url, chrome_session_id, website, pipe, overlay_queue, stockfish_path, enable_manual_mode, enable_mouseless_mode, enable_non_stop_puzzles, enable_non_stop_matches, mouse_latency, bongcloud, slow_mover, skill_level, stockfish_depth, memory, cpu_threads): 17 | multiprocess.Process.__init__(self) 18 | 19 | self.chrome_url = chrome_url 20 | self.chrome_session_id = chrome_session_id 21 | self.website = website 22 | self.pipe = pipe 23 | self.overlay_queue = overlay_queue 24 | self.stockfish_path = stockfish_path 25 | self.enable_manual_mode = enable_manual_mode 26 | self.enable_mouseless_mode = enable_mouseless_mode 27 | self.enable_non_stop_puzzles = enable_non_stop_puzzles 28 | self.enable_non_stop_matches = enable_non_stop_matches 29 | self.mouse_latency = mouse_latency 30 | self.bongcloud = bongcloud 31 | self.slow_mover = slow_mover 32 | self.skill_level = skill_level 33 | self.stockfish_depth = stockfish_depth 34 | self.grabber = None 35 | self.memory = memory 36 | self.cpu_threads = cpu_threads 37 | self.is_white = None 38 | 39 | # Converts a move to screen coordinates 40 | # Example: "a1" -> (x, y) 41 | def move_to_screen_pos(self, move): 42 | # Get the absolute top left corner of the website 43 | canvas_x_offset, canvas_y_offset = self.grabber.get_top_left_corner() 44 | 45 | # Get the absolute board position 46 | board_x = canvas_x_offset + self.grabber.get_board().location["x"] 47 | board_y = canvas_y_offset + self.grabber.get_board().location["y"] 48 | 49 | # Get the square size 50 | square_size = self.grabber.get_board().size['width'] / 8 51 | 52 | # Depending on the player color, the board is flipped, so the coordinates need to be adjusted 53 | if self.is_white: 54 | x = board_x + square_size * (char_to_num(move[0]) - 1) + square_size / 2 55 | y = board_y + square_size * (8 - int(move[1])) + square_size / 2 56 | else: 57 | x = board_x + square_size * (8 - char_to_num(move[0])) + square_size / 2 58 | y = board_y + square_size * (int(move[1]) - 1) + square_size / 2 59 | 60 | return x, y 61 | 62 | def get_move_pos(self, move): # sourcery skip: remove-redundant-slice-index 63 | # Get the start and end position screen coordinates 64 | start_pos_x, start_pos_y = self.move_to_screen_pos(move[0:2]) 65 | end_pos_x, end_pos_y = self.move_to_screen_pos(move[2:4]) 66 | 67 | return (start_pos_x, start_pos_y), (end_pos_x, end_pos_y) 68 | 69 | 70 | def make_move(self, move): # sourcery skip: extract-method 71 | # Get the start and end position screen coordinates 72 | start_pos, end_pos = self.get_move_pos(move) 73 | 74 | # Drag the piece from the start to the end position 75 | pyautogui.moveTo(start_pos[0], start_pos[1]) 76 | time.sleep(self.mouse_latency) 77 | pyautogui.dragTo(end_pos[0], end_pos[1]) 78 | 79 | # Check for promotion. If there is a promotion, 80 | # promote to the corresponding piece type 81 | if len(move) == 5: 82 | time.sleep(0.1) 83 | end_pos_x = None 84 | end_pos_y = None 85 | if move[4] == "n": 86 | end_pos_x, end_pos_y = self.move_to_screen_pos(move[2] + str(int(move[3]) - 1)) 87 | elif move[4] == "r": 88 | end_pos_x, end_pos_y = self.move_to_screen_pos(move[2] + str(int(move[3]) - 2)) 89 | elif move[4] == "b": 90 | end_pos_x, end_pos_y = self.move_to_screen_pos(move[2] + str(int(move[3]) - 3)) 91 | 92 | pyautogui.moveTo(x=end_pos_x, y=end_pos_y) 93 | pyautogui.click(button='left') 94 | 95 | def wait_for_gui_to_delete(self): 96 | while self.pipe.recv() != "DELETE": 97 | pass 98 | 99 | def go_to_next_puzzle(self): 100 | self.grabber.click_puzzle_next() 101 | self.pipe.send("RESTART") 102 | self.wait_for_gui_to_delete() 103 | 104 | def find_new_online_match(self): 105 | time.sleep(2) 106 | self.grabber.click_game_next() 107 | self.pipe.send("RESTART") 108 | self.wait_for_gui_to_delete() 109 | 110 | def run(self): 111 | # sourcery skip: extract-duplicate-method, switch, use-fstring-for-concatenation 112 | if self.website == "chesscom": 113 | self.grabber = ChesscomGrabber(self.chrome_url, self.chrome_session_id) 114 | else: 115 | self.grabber = LichessGrabber(self.chrome_url, self.chrome_session_id) 116 | 117 | # Reset the grabber's moves list to ensure a clean start 118 | self.grabber.reset_moves_list() 119 | 120 | # Initialize Stockfish 121 | parameters = { 122 | "Threads": self.cpu_threads, 123 | "Hash": self.memory, 124 | "Ponder": "true", 125 | "Slow Mover": self.slow_mover, 126 | "Skill Level": self.skill_level 127 | } 128 | try: 129 | stockfish = Stockfish(path=self.stockfish_path, depth=self.stockfish_depth, parameters=parameters) 130 | except PermissionError: 131 | self.pipe.send("ERR_PERM") 132 | return 133 | except OSError: 134 | self.pipe.send("ERR_EXE") 135 | return 136 | 137 | try: 138 | # Return if the board element is not found 139 | self.grabber.update_board_elem() 140 | if self.grabber.get_board() is None: 141 | self.pipe.send("ERR_BOARD") 142 | return 143 | 144 | # Find out what color the player has 145 | self.is_white = self.grabber.is_white() 146 | if self.is_white is None: 147 | self.pipe.send("ERR_COLOR") 148 | return 149 | 150 | # Get the starting position 151 | # Return if the starting position is not found 152 | move_list = self.grabber.get_move_list() 153 | if move_list is None: 154 | self.pipe.send("ERR_MOVES") 155 | return 156 | 157 | # Check if the game is over 158 | score_pattern = r"([0-9]+)\-([0-9]+)" 159 | if len(move_list) > 0 and re.match(score_pattern, move_list[-1]): 160 | self.pipe.send("ERR_GAMEOVER") 161 | return 162 | 163 | # Update the board with the starting position 164 | board = chess.Board() 165 | for move in move_list: 166 | board.push_san(move) 167 | move_list_uci = [move.uci() for move in board.move_stack] 168 | 169 | # Update Stockfish with the starting position 170 | stockfish.set_position(move_list_uci) 171 | 172 | # Track player moves and best moves for accuracy calculation 173 | white_moves = [] 174 | white_best_moves = [] 175 | black_moves = [] 176 | black_best_moves = [] 177 | 178 | # Send initial evaluation, WDL, and material data to GUI 179 | self.send_eval_data(stockfish, board) 180 | 181 | # Notify GUI that bot is ready 182 | self.pipe.send("START") 183 | 184 | # Send the first moves to the GUI (if there are any) 185 | if len(move_list) > 0: 186 | self.pipe.send("M_MOVE" + ",".join(move_list)) 187 | 188 | # Start the game loop 189 | while True: 190 | # Act if it is the player's turn 191 | if (self.is_white and board.turn == chess.WHITE) or (not self.is_white and board.turn == chess.BLACK): 192 | # Think of a move 193 | move = None 194 | move_count = len(board.move_stack) 195 | if self.bongcloud and move_count <= 3: 196 | if move_count == 0: 197 | move = "e2e3" 198 | elif move_count == 1: 199 | move = "e7e6" 200 | elif move_count == 2: 201 | move = "e1e2" 202 | elif move_count == 3: 203 | move = "e8e7" 204 | 205 | # Hardcoded bongcloud move is not legal, 206 | # so find a legal move 207 | if not board.is_legal(chess.Move.from_uci(move)): 208 | move = stockfish.get_best_move() 209 | else: 210 | move = stockfish.get_best_move() 211 | 212 | # Store best move for accuracy calculation 213 | if board.turn == chess.WHITE: 214 | white_best_moves.append(move) 215 | else: 216 | black_best_moves.append(move) 217 | 218 | # Wait for keypress or player movement if in manual mode 219 | self_moved = False 220 | if self.enable_manual_mode: 221 | move_start_pos, move_end_pos = self.get_move_pos(move) 222 | self.overlay_queue.put([ 223 | ((int(move_start_pos[0]), int(move_start_pos[1])), (int(move_end_pos[0]), int(move_end_pos[1]))), 224 | ]) 225 | while True: 226 | if keyboard.is_pressed("3"): 227 | break 228 | 229 | if len(move_list) != len(self.grabber.get_move_list()): 230 | self_moved = True 231 | move_list = self.grabber.get_move_list() 232 | move_san = move_list[-1] 233 | move = board.parse_san(move_san).uci() 234 | # Store actual move for accuracy calculation 235 | if board.turn == chess.WHITE: 236 | white_moves.append(move) 237 | else: 238 | black_moves.append(move) 239 | board.push_uci(move) 240 | stockfish.make_moves_from_current_position([move]) 241 | break 242 | 243 | if not self_moved: 244 | move_san = board.san(chess.Move(chess.parse_square(move[0:2]), chess.parse_square(move[2:4]))) 245 | # Store actual move for accuracy calculation 246 | if board.turn == chess.WHITE: 247 | white_moves.append(move) 248 | else: 249 | black_moves.append(move) 250 | board.push_uci(move) 251 | stockfish.make_moves_from_current_position([move]) 252 | move_list.append(move_san) 253 | if self.enable_mouseless_mode and not self.grabber.is_game_puzzles(): 254 | self.grabber.make_mouseless_move(move, move_count + 1) 255 | else: 256 | self.make_move(move) 257 | 258 | self.overlay_queue.put([]) 259 | 260 | # Send evaluation, WDL, and material data to GUI 261 | self.send_eval_data(stockfish, board, white_moves, white_best_moves, black_moves, black_best_moves) 262 | 263 | # Send the move to the GUI 264 | self.pipe.send("S_MOVE" + move_san) 265 | 266 | # Check if the game is over 267 | if board.is_checkmate(): 268 | # Send restart message to GUI 269 | if self.enable_non_stop_puzzles and self.grabber.is_game_puzzles(): 270 | self.go_to_next_puzzle() 271 | elif self.enable_non_stop_matches and not self.enable_non_stop_puzzles: 272 | self.find_new_online_match() 273 | return 274 | 275 | time.sleep(0.1) 276 | 277 | # Wait for a response from the opponent 278 | # by finding the differences between 279 | # the previous and current position 280 | previous_move_list = move_list.copy() 281 | while True: 282 | if self.grabber.is_game_over(): 283 | # Send restart message to GUI 284 | if self.enable_non_stop_puzzles and self.grabber.is_game_puzzles(): 285 | self.go_to_next_puzzle() 286 | elif self.enable_non_stop_matches and not self.enable_non_stop_puzzles: 287 | self.find_new_online_match() 288 | return 289 | 290 | # Get fresh move list from the grabber and check if it's a new game 291 | new_move_list = self.grabber.get_move_list() 292 | if new_move_list is None: 293 | return 294 | 295 | # Check if this is a completely new game (moves reset to 0) 296 | if len(new_move_list) == 0 and len(move_list) > 0: 297 | # Reset everything for the new game 298 | move_list = [] 299 | board = chess.Board() 300 | stockfish.set_position([]) 301 | # Reset accuracy tracking 302 | white_moves = [] 303 | white_best_moves = [] 304 | black_moves = [] 305 | black_best_moves = [] 306 | # Find out what color the player has for the new game 307 | self.is_white = self.grabber.is_white() 308 | self.pipe.send("RESTART") 309 | self.wait_for_gui_to_delete() 310 | # Send initial evaluation, WDL, and material data to GUI 311 | self.send_eval_data(stockfish, board) 312 | self.pipe.send("START") 313 | break 314 | 315 | # Normal case - opponent made a move 316 | if len(new_move_list) > len(previous_move_list): 317 | move_list = new_move_list 318 | break 319 | 320 | # Get the move that the opponent made 321 | move = move_list[-1] 322 | # Get UCI version of the move for accuracy tracking 323 | prev_board = board.copy() 324 | board.push_san(move) 325 | move_uci = prev_board.parse_san(move).uci() 326 | 327 | # Store actual move for accuracy calculation 328 | if prev_board.turn == chess.WHITE: 329 | white_moves.append(move_uci) 330 | else: 331 | black_moves.append(move_uci) 332 | 333 | # Get and store the best move that should have been played 334 | best_move = stockfish.get_best_move_time(300) # Get best move with 300ms of thinking time 335 | if prev_board.turn == chess.WHITE: 336 | white_best_moves.append(best_move) 337 | else: 338 | black_best_moves.append(best_move) 339 | 340 | # Send evaluation, WDL, and material data to GUI 341 | stockfish.make_moves_from_current_position([str(board.peek())]) 342 | self.send_eval_data(stockfish, board, white_moves, white_best_moves, black_moves, black_best_moves) 343 | 344 | # Send the move to the GUI 345 | self.pipe.send("S_MOVE" + move) 346 | 347 | if board.is_checkmate(): 348 | # Send restart message to GUI 349 | if self.enable_non_stop_puzzles and self.grabber.is_game_puzzles(): 350 | self.go_to_next_puzzle() 351 | elif self.enable_non_stop_matches and not self.enable_non_stop_puzzles: 352 | self.find_new_online_match() 353 | return 354 | except Exception as e: 355 | print(e) 356 | exc_type, exc_obj, exc_tb = sys.exc_info() 357 | fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] 358 | print(exc_type, fname, exc_tb.tb_lineno) 359 | 360 | def send_eval_data(self, stockfish, board, white_moves=None, white_best_moves=None, black_moves=None, black_best_moves=None): 361 | """Send evaluation, WDL, and material data to GUI""" 362 | try: 363 | # Get evaluation 364 | eval_data = stockfish.get_evaluation() 365 | eval_type = eval_data['type'] 366 | eval_value = eval_data['value'] 367 | 368 | # Convert evaluation to player's perspective if playing as black 369 | # Stockfish eval is always from white's perspective (+ve for white, -ve for black) 370 | player_perspective_eval_value = eval_value 371 | if not self.is_white: 372 | player_perspective_eval_value = -eval_value # Negate to get black's perspective 373 | 374 | # Get WDL stats if available 375 | try: 376 | wdl_stats = stockfish.get_wdl_stats() 377 | except: 378 | wdl_stats = [0, 0, 0] 379 | 380 | # Calculate material advantage (basic version) 381 | material = self.calculate_material_advantage(board) 382 | 383 | # Calculate accuracy if enough moves 384 | white_accuracy = "-" 385 | black_accuracy = "-" 386 | if white_moves and white_best_moves and len(white_moves) > 0 and len(white_moves) == len(white_best_moves): 387 | matches = sum(1 for a, b in zip(white_moves, white_best_moves) if a == b) 388 | white_accuracy = f"{matches / len(white_moves) * 100:.1f}%" 389 | 390 | if black_moves and black_best_moves and len(black_moves) > 0 and len(black_moves) == len(black_best_moves): 391 | matches = sum(1 for a, b in zip(black_moves, black_best_moves) if a == b) 392 | black_accuracy = f"{matches / len(black_moves) * 100:.1f}%" 393 | 394 | # Format evaluation string from player's perspective 395 | if eval_type == "cp": 396 | eval_str = f"{player_perspective_eval_value/100:.2f}" 397 | # Convert centipawns to decimal value for the eval bar 398 | eval_value_decimal = player_perspective_eval_value/100 399 | else: # mate 400 | eval_str = f"M{player_perspective_eval_value}" 401 | eval_value_decimal = player_perspective_eval_value # Keep mate score as is 402 | 403 | # Format WDL string (win/draw/loss percentages) 404 | total = sum(wdl_stats) 405 | if total > 0: 406 | # WDL from Stockfish is from perspective of player to move 407 | # Need to invert if it's opponent's turn 408 | is_bot_turn = (self.is_white and board.turn == chess.WHITE) or (not self.is_white and board.turn == chess.BLACK) 409 | 410 | if is_bot_turn: 411 | win_pct = wdl_stats[0] / total * 100 412 | draw_pct = wdl_stats[1] / total * 100 413 | loss_pct = wdl_stats[2] / total * 100 414 | else: 415 | # Invert the win/loss when it's opponent's turn 416 | win_pct = wdl_stats[2] / total * 100 417 | draw_pct = wdl_stats[1] / total * 100 418 | loss_pct = wdl_stats[0] / total * 100 419 | 420 | wdl_str = f"{win_pct:.1f}/{draw_pct:.1f}/{loss_pct:.1f}" 421 | else: 422 | wdl_str = "?/?/?" 423 | 424 | # Determine bot and opponent accuracies based on bot's color 425 | bot_accuracy = white_accuracy if self.is_white else black_accuracy 426 | opponent_accuracy = black_accuracy if self.is_white else white_accuracy 427 | 428 | # Send data to GUI 429 | data = f"EVAL|{eval_str}|{wdl_str}|{material}|{bot_accuracy}|{opponent_accuracy}" 430 | self.pipe.send(data) 431 | 432 | # Send evaluation data to overlay 433 | overlay_data = { 434 | "eval": eval_value_decimal, 435 | "eval_type": eval_type 436 | } 437 | 438 | # Add board position and dimensions for the eval bar positioning 439 | board_elem = self.grabber.get_board() 440 | if board_elem: 441 | # Get the absolute top left corner of the website 442 | canvas_x_offset, canvas_y_offset = self.grabber.get_top_left_corner() 443 | 444 | # Calculate absolute board position and dimensions 445 | overlay_data["board_position"] = { 446 | 'x': canvas_x_offset + board_elem.location['x'], 447 | 'y': canvas_y_offset + board_elem.location['y'], 448 | 'width': board_elem.size['width'], 449 | 'height': board_elem.size['height'] 450 | } 451 | 452 | # Always include the bot's color 453 | overlay_data["is_white"] = self.is_white 454 | 455 | self.overlay_queue.put(overlay_data) 456 | 457 | except Exception as e: 458 | print(f"Error sending evaluation: {e}") 459 | 460 | def calculate_material_advantage(self, board): 461 | """Calculate material advantage in the position""" 462 | piece_values = { 463 | chess.PAWN: 1, 464 | chess.KNIGHT: 3, 465 | chess.BISHOP: 3, 466 | chess.ROOK: 5, 467 | chess.QUEEN: 9 468 | } 469 | 470 | white_material = 0 471 | black_material = 0 472 | 473 | for piece_type in piece_values: 474 | white_material += len(board.pieces(piece_type, chess.WHITE)) * piece_values[piece_type] 475 | black_material += len(board.pieces(piece_type, chess.BLACK)) * piece_values[piece_type] 476 | 477 | advantage = white_material - black_material 478 | if advantage > 0: 479 | return f"+{advantage}" 480 | elif advantage < 0: 481 | return str(advantage) 482 | else: 483 | return "0" 484 | -------------------------------------------------------------------------------- /src/gui.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import multiprocess 4 | import threading 5 | import time 6 | import tkinter as tk 7 | from tkinter import ttk, filedialog 8 | from selenium import webdriver 9 | from selenium.webdriver.chrome.service import Service 10 | from webdriver_manager.chrome import ChromeDriverManager 11 | from selenium.webdriver.chrome.service import Service as ChromeService 12 | from overlay import run 13 | from stockfish_bot import StockfishBot 14 | from selenium.common import WebDriverException 15 | import keyboard 16 | 17 | 18 | class GUI: 19 | def __init__(self, master): 20 | self.master = master 21 | 22 | # Used for closing the threads 23 | self.exit = False 24 | 25 | # The Selenium Chrome driver 26 | self.chrome = None 27 | 28 | # # Used for storing the Stockfish Bot class Instance 29 | # self.stockfish_bot = None 30 | self.chrome_url = None 31 | self.chrome_session_id = None 32 | 33 | # Used for the communication between the GUI 34 | # and the Stockfish Bot process 35 | self.stockfish_bot_pipe = None 36 | self.overlay_screen_pipe = None 37 | 38 | # The Stockfish Bot process 39 | self.stockfish_bot_process = None 40 | self.overlay_screen_process = None 41 | self.restart_after_stopping = False 42 | 43 | # Used for storing the match moves 44 | self.match_moves = [] 45 | 46 | # Set the window properties 47 | master.title("Chess") 48 | master.geometry("") 49 | master.iconphoto(True, tk.PhotoImage(file="src/assets/pawn_32x32.png")) 50 | master.resizable(False, False) 51 | master.attributes("-topmost", True) 52 | master.protocol("WM_DELETE_WINDOW", self.on_close_listener) 53 | 54 | # Change the style 55 | style = ttk.Style() 56 | style.theme_use("clam") 57 | 58 | # Left frame 59 | left_frame = tk.Frame(master) 60 | 61 | # Create the status text 62 | status_label = tk.Frame(left_frame) 63 | tk.Label(status_label, text="Status:").pack(side=tk.LEFT) 64 | self.status_text = tk.Label(status_label, text="Inactive", fg="red") 65 | self.status_text.pack() 66 | status_label.pack(anchor=tk.NW) 67 | 68 | # Create the evaluation info 69 | self.eval_frame = tk.Frame(left_frame) 70 | 71 | # Evaluation (centipawns) 72 | eval_label = tk.Frame(self.eval_frame) 73 | tk.Label(eval_label, text="Eval:").pack(side=tk.LEFT) 74 | self.eval_text = tk.Label(eval_label, text="-") 75 | self.eval_text.pack() 76 | eval_label.pack(anchor=tk.NW) 77 | 78 | # WDL (win/draw/loss) 79 | wdl_label = tk.Frame(self.eval_frame) 80 | tk.Label(wdl_label, text="WDL:").pack(side=tk.LEFT) 81 | self.wdl_text = tk.Label(wdl_label, text="-") 82 | self.wdl_text.pack() 83 | wdl_label.pack(anchor=tk.NW) 84 | 85 | # Material advantage 86 | material_label = tk.Frame(self.eval_frame) 87 | tk.Label(material_label, text="Material:").pack(side=tk.LEFT) 88 | self.material_text = tk.Label(material_label, text="-") 89 | self.material_text.pack() 90 | material_label.pack(anchor=tk.NW) 91 | 92 | # White player accuracy 93 | white_acc_label = tk.Frame(self.eval_frame) 94 | tk.Label(white_acc_label, text="Bot Acc:").pack(side=tk.LEFT) 95 | self.white_acc_text = tk.Label(white_acc_label, text="-") 96 | self.white_acc_text.pack() 97 | white_acc_label.pack(anchor=tk.NW) 98 | 99 | # Black player accuracy 100 | black_acc_label = tk.Frame(self.eval_frame) 101 | tk.Label(black_acc_label, text="Opponent Acc:").pack(side=tk.LEFT) 102 | self.black_acc_text = tk.Label(black_acc_label, text="-") 103 | self.black_acc_text.pack() 104 | black_acc_label.pack(anchor=tk.NW) 105 | 106 | self.eval_frame.pack(anchor=tk.NW) 107 | 108 | # Create the website chooser radio buttons 109 | self.website = tk.StringVar(value="chesscom") 110 | self.chesscom_radio_button = tk.Radiobutton( 111 | left_frame, 112 | text="Chess.com", 113 | variable=self.website, 114 | value="chesscom" 115 | ) 116 | self.chesscom_radio_button.pack(anchor=tk.NW) 117 | self.lichess_radio_button = tk.Radiobutton( 118 | left_frame, 119 | text="Lichess.org", 120 | variable=self.website, 121 | value="lichess" 122 | ) 123 | self.lichess_radio_button.pack(anchor=tk.NW) 124 | 125 | # Create the open browser button 126 | self.opening_browser = False 127 | self.opened_browser = False 128 | self.open_browser_button = tk.Button( 129 | left_frame, 130 | text="Open Browser", 131 | command=self.on_open_browser_button_listener, 132 | ) 133 | self.open_browser_button.pack(anchor=tk.NW) 134 | 135 | # Create the start button 136 | self.running = False 137 | self.start_button = tk.Button( 138 | left_frame, text="Start", command=self.on_start_button_listener 139 | ) 140 | self.start_button["state"] = "disabled" 141 | self.start_button.pack(anchor=tk.NW, pady=5) 142 | 143 | # Create the manual mode checkbox 144 | self.enable_manual_mode = tk.BooleanVar(value=False) 145 | self.manual_mode_checkbox = tk.Checkbutton( 146 | left_frame, 147 | text="Manual Mode", 148 | variable=self.enable_manual_mode, 149 | command=self.on_manual_mode_checkbox_listener, 150 | ) 151 | self.manual_mode_checkbox.pack(anchor=tk.NW) 152 | 153 | # Create the manual mode instructions 154 | self.manual_mode_frame = tk.Frame(left_frame) 155 | self.manual_mode_label = tk.Label( 156 | self.manual_mode_frame, text="\u2022 Press 3 to make a move" 157 | ) 158 | self.manual_mode_label.pack(anchor=tk.NW) 159 | 160 | # Create the mouseless mode checkbox 161 | self.enable_mouseless_mode = tk.BooleanVar(value=False) 162 | self.mouseless_mode_checkbox = tk.Checkbutton( 163 | left_frame, 164 | text="Mouseless Mode", 165 | variable=self.enable_mouseless_mode 166 | ) 167 | self.mouseless_mode_checkbox.pack(anchor=tk.NW) 168 | 169 | # Create the non-stop puzzles check button 170 | self.enable_non_stop_puzzles = tk.IntVar(value=0) 171 | self.non_stop_puzzles_check_button = tk.Checkbutton( 172 | left_frame, 173 | text="Non-stop puzzles", 174 | variable=self.enable_non_stop_puzzles 175 | ) 176 | self.non_stop_puzzles_check_button.pack(anchor=tk.NW) 177 | 178 | # Create the non-stop matches check button 179 | self.enable_non_stop_matches = tk.IntVar(value=0) 180 | self.non_stop_matches_check_button = tk.Checkbutton(left_frame, text="Non-stop online matches", 181 | variable=self.enable_non_stop_matches) 182 | self.non_stop_matches_check_button.pack(anchor=tk.NW) 183 | 184 | # Create the bongcloud check button 185 | self.enable_bongcloud = tk.IntVar() 186 | self.bongcloud_check_button = tk.Checkbutton( 187 | left_frame, 188 | text="Bongcloud", 189 | variable=self.enable_bongcloud 190 | ) 191 | self.bongcloud_check_button.pack(anchor=tk.NW) 192 | 193 | # Create the mouse latency scale 194 | mouse_latency_frame = tk.Frame(left_frame) 195 | tk.Label(mouse_latency_frame, text="Mouse Latency (seconds)").pack(side=tk.LEFT, pady=(17, 0)) 196 | self.mouse_latency = tk.DoubleVar(value=0.0) 197 | self.mouse_latency_scale = tk.Scale(mouse_latency_frame, from_=0.0, to=15, resolution=0.2, orient=tk.HORIZONTAL, 198 | variable=self.mouse_latency) 199 | self.mouse_latency_scale.pack() 200 | mouse_latency_frame.pack(anchor=tk.NW) 201 | 202 | # Separator 203 | separator_frame = tk.Frame(left_frame) 204 | separator = ttk.Separator(separator_frame, orient="horizontal") 205 | separator.grid(row=0, column=0, sticky="ew") 206 | label = tk.Label(separator_frame, text="Stockfish parameters") 207 | label.grid(row=0, column=0, padx=40) 208 | separator_frame.pack(anchor=tk.NW, pady=10, expand=True, fill=tk.X) 209 | 210 | # Create the Slow mover entry field 211 | slow_mover_frame = tk.Frame(left_frame) 212 | self.slow_mover_label = tk.Label(slow_mover_frame, text="Slow Mover") 213 | self.slow_mover_label.pack(side=tk.LEFT) 214 | self.slow_mover = tk.IntVar(value=100) 215 | self.slow_mover_entry = tk.Entry( 216 | slow_mover_frame, textvariable=self.slow_mover, justify="center", width=8 217 | ) 218 | self.slow_mover_entry.pack() 219 | slow_mover_frame.pack(anchor=tk.NW) 220 | 221 | # Create the skill level scale 222 | skill_level_frame = tk.Frame(left_frame) 223 | tk.Label(skill_level_frame, text="Skill Level").pack(side=tk.LEFT, pady=(19, 0)) 224 | self.skill_level = tk.IntVar(value=20) 225 | self.skill_level_scale = tk.Scale( 226 | skill_level_frame, 227 | from_=0, 228 | to=20, 229 | orient=tk.HORIZONTAL, 230 | variable=self.skill_level, 231 | ) 232 | self.skill_level_scale.pack() 233 | skill_level_frame.pack(anchor=tk.NW) 234 | 235 | # Create the Stockfish depth scale 236 | stockfish_depth_frame = tk.Frame(left_frame) 237 | tk.Label(stockfish_depth_frame, text="Depth").pack(side=tk.LEFT, pady=19) 238 | self.stockfish_depth = tk.IntVar(value=15) 239 | self.stockfish_depth_scale = tk.Scale( 240 | stockfish_depth_frame, 241 | from_=1, 242 | to=20, 243 | orient=tk.HORIZONTAL, 244 | variable=self.stockfish_depth, 245 | ) 246 | self.stockfish_depth_scale.pack() 247 | stockfish_depth_frame.pack(anchor=tk.NW) 248 | 249 | # Create the memory entry field 250 | memory_frame = tk.Frame(left_frame) 251 | tk.Label(memory_frame, text="Memory").pack(side=tk.LEFT) 252 | self.memory = tk.IntVar(value=512) 253 | self.memory_entry = tk.Entry( 254 | memory_frame, textvariable=self.memory, justify="center", width=9 255 | ) 256 | self.memory_entry.pack(side=tk.LEFT) 257 | tk.Label(memory_frame, text="MB").pack() 258 | memory_frame.pack(anchor=tk.NW, pady=(0, 15)) 259 | 260 | # Create the CPU threads entry field 261 | cpu_threads_frame = tk.Frame(left_frame) 262 | tk.Label(cpu_threads_frame, text="CPU Threads").pack(side=tk.LEFT) 263 | self.cpu_threads = tk.IntVar(value=1) 264 | self.cpu_threads_entry = tk.Entry( 265 | cpu_threads_frame, textvariable=self.cpu_threads, justify="center", width=7 266 | ) 267 | self.cpu_threads_entry.pack() 268 | cpu_threads_frame.pack(anchor=tk.NW) 269 | 270 | # Separator 271 | separator_frame = tk.Frame(left_frame) 272 | separator = ttk.Separator(separator_frame, orient="horizontal") 273 | separator.grid(row=0, column=0, sticky="ew") 274 | label = tk.Label(separator_frame, text="Misc") 275 | label.grid(row=0, column=0, padx=82) 276 | separator_frame.pack(anchor=tk.NW, pady=10, expand=True, fill=tk.X) 277 | 278 | # Create the topmost check button 279 | self.enable_topmost = tk.IntVar(value=1) 280 | self.topmost_check_button = tk.Checkbutton( 281 | left_frame, 282 | text="Window stays on top", 283 | variable=self.enable_topmost, 284 | onvalue=1, 285 | offvalue=0, 286 | command=self.on_topmost_check_button_listener, 287 | ) 288 | self.topmost_check_button.pack(anchor=tk.NW) 289 | 290 | # Create the select stockfish button 291 | self.stockfish_path = "" 292 | self.select_stockfish_button = tk.Button( 293 | left_frame, 294 | text="Select Stockfish", 295 | command=self.on_select_stockfish_button_listener, 296 | ) 297 | self.select_stockfish_button.pack(anchor=tk.NW) 298 | 299 | # Create the stockfish path text 300 | self.stockfish_path_text = tk.Label(left_frame, text="", wraplength=180) 301 | self.stockfish_path_text.pack(anchor=tk.NW) 302 | 303 | left_frame.grid(row=0, column=0, padx=5, sticky=tk.NW) 304 | 305 | # Right frame 306 | right_frame = tk.Frame(master) 307 | 308 | # Treeview frame 309 | treeview_frame = tk.Frame(right_frame) 310 | 311 | # Create the moves Treeview 312 | self.tree = ttk.Treeview( 313 | treeview_frame, 314 | column=("#", "White", "Black"), 315 | show="headings", 316 | height=23, 317 | selectmode="browse", 318 | ) 319 | self.tree.pack(anchor=tk.NW, side=tk.LEFT) 320 | 321 | # # Add the scrollbar to the Treeview 322 | self.vsb = ttk.Scrollbar( 323 | treeview_frame, 324 | orient="vertical", 325 | command=self.tree.yview 326 | ) 327 | self.vsb.pack(fill=tk.Y, expand=True) 328 | self.tree.configure(yscrollcommand=self.vsb.set) 329 | 330 | # Create the columns 331 | self.tree.column("# 1", anchor=tk.CENTER, width=35) 332 | self.tree.heading("# 1", text="#") 333 | self.tree.column("# 2", anchor=tk.CENTER, width=60) 334 | self.tree.heading("# 2", text="White") 335 | self.tree.column("# 3", anchor=tk.CENTER, width=60) 336 | self.tree.heading("# 3", text="Black") 337 | 338 | treeview_frame.pack(anchor=tk.NW) 339 | 340 | # Create the export PGN button 341 | self.export_pgn_button = tk.Button( 342 | right_frame, text="Export PGN", command=self.on_export_pgn_button_listener 343 | ) 344 | self.export_pgn_button.pack(anchor=tk.NW, fill=tk.X) 345 | 346 | right_frame.grid(row=0, column=1, sticky=tk.NW) 347 | 348 | # Start the process checker thread 349 | process_checker_thread = threading.Thread(target=self.process_checker_thread) 350 | process_checker_thread.start() 351 | 352 | # Start the browser checker thread 353 | browser_checker_thread = threading.Thread(target=self.browser_checker_thread) 354 | browser_checker_thread.start() 355 | 356 | # Start the process communicator thread 357 | process_communicator_thread = threading.Thread( 358 | target=self.process_communicator_thread 359 | ) 360 | process_communicator_thread.start() 361 | 362 | # Start the keyboard listener thread 363 | keyboard_listener_thread = threading.Thread( 364 | target=self.keypress_listener_thread 365 | ) 366 | keyboard_listener_thread.start() 367 | 368 | # Detects if the user pressed the close button 369 | def on_close_listener(self): 370 | # Set self.exit to True so that the threads will stop 371 | self.exit = True 372 | self.master.destroy() 373 | 374 | # Detects if the Stockfish Bot process is running 375 | def process_checker_thread(self): 376 | while not self.exit: 377 | if ( 378 | self.running 379 | and self.stockfish_bot_process is not None 380 | and not self.stockfish_bot_process.is_alive() 381 | ): 382 | self.on_stop_button_listener() 383 | 384 | # Restart the process if restart_after_stopping is True 385 | if self.restart_after_stopping: 386 | self.restart_after_stopping = False 387 | self.on_start_button_listener() 388 | time.sleep(0.1) 389 | 390 | # Detects if Selenium Chromedriver is running 391 | def browser_checker_thread(self): 392 | while not self.exit: 393 | try: 394 | if ( 395 | self.opened_browser 396 | and self.chrome is not None 397 | and "target window already closed" 398 | in self.chrome.get_log("driver")[-1]["message"] 399 | ): 400 | self.opened_browser = False 401 | 402 | # Set Opening Browser button state to closed 403 | self.open_browser_button["text"] = "Open Browser" 404 | self.open_browser_button["state"] = "normal" 405 | self.open_browser_button.update() 406 | 407 | self.on_stop_button_listener() 408 | self.chrome = None 409 | except IndexError: 410 | pass 411 | time.sleep(0.1) 412 | 413 | # Responsible for communicating with the Stockfish Bot process 414 | # The pipe can receive the following commands: 415 | # - "START": Resets and starts the Stockfish Bot 416 | # - "S_MOVE": Sends the Stockfish Bot a single move to make 417 | # Ex. "S_MOVEe4 418 | # - "M_MOVE": Sends the Stockfish Bot multiple moves to make 419 | # Ex. "S_MOVEe4,c5,Nf3 420 | # - "ERR_EXE": Notifies the GUI that the Stockfish Bot can't initialize Stockfish 421 | # - "ERR_PERM": Notifies the GUI that the Stockfish Bot can't execute the Stockfish executable 422 | # - "ERR_BOARD": Notifies the GUI that the Stockfish Bot can't find the board 423 | # - "ERR_COLOR": Notifies the GUI that the Stockfish Bot can't find the player color 424 | # - "ERR_MOVES": Notifies the GUI that the Stockfish Bot can't find the moves list 425 | # - "ERR_GAMEOVER": Notifies the GUI that the current game is already over 426 | def process_communicator_thread(self): 427 | while not self.exit: 428 | try: 429 | if ( 430 | self.stockfish_bot_pipe is not None 431 | and self.stockfish_bot_pipe.poll() 432 | ): 433 | data = self.stockfish_bot_pipe.recv() 434 | if data == "START": 435 | self.clear_tree() 436 | self.match_moves = [] 437 | 438 | # Update the status text 439 | self.status_text["text"] = "Running" 440 | self.status_text["fg"] = "green" 441 | self.status_text.update() 442 | 443 | # Update the run button 444 | self.start_button["text"] = "Stop" 445 | self.start_button["state"] = "normal" 446 | self.start_button["command"] = self.on_stop_button_listener 447 | self.start_button.update() 448 | elif data[:7] == "RESTART": 449 | self.restart_after_stopping = True 450 | self.stockfish_bot_pipe.send("DELETE") 451 | elif data[:6] == "S_MOVE": 452 | move = data[6:] 453 | self.match_moves.append(move) 454 | self.insert_move(move) 455 | self.tree.yview_moveto(1) 456 | elif data[:6] == "M_MOVE": 457 | moves = data[6:].split(",") 458 | self.match_moves += moves 459 | self.set_moves(moves) 460 | self.tree.yview_moveto(1) 461 | elif data[:5] == "EVAL|": 462 | # Parse evaluation data 463 | parts = data.split("|") 464 | if len(parts) >= 5: 465 | eval_str, wdl_str, material_str, bot_accuracy_str, opponent_accuracy_str = parts[1:] 466 | 467 | bot_acc = bot_accuracy_str 468 | opponent_acc = opponent_accuracy_str 469 | 470 | # Update the evaluation info 471 | self.update_evaluation_display(eval_str, wdl_str, material_str, bot_acc, opponent_acc) 472 | elif data[:7] == "ERR_EXE": 473 | tk.messagebox.showerror( 474 | "Error", 475 | "Stockfish path provided is not valid!" 476 | ) 477 | elif data[:8] == "ERR_PERM": 478 | tk.messagebox.showerror( 479 | "Error", 480 | "Stockfish path provided is not executable!" 481 | ) 482 | elif data[:9] == "ERR_BOARD": 483 | tk.messagebox.showerror( 484 | "Error", 485 | "Cant find board!" 486 | ) 487 | elif data[:9] == "ERR_COLOR": 488 | tk.messagebox.showerror( 489 | "Error", 490 | "Cant find player color!" 491 | ) 492 | elif data[:9] == "ERR_MOVES": 493 | tk.messagebox.showerror( 494 | "Error", 495 | "Cant find moves list!" 496 | ) 497 | elif data[:12] == "ERR_GAMEOVER": 498 | tk.messagebox.showerror( 499 | "Error", 500 | "Game has already finished!" 501 | ) 502 | except (BrokenPipeError, OSError): 503 | self.stockfish_bot_pipe = None 504 | 505 | time.sleep(0.1) 506 | 507 | def keypress_listener_thread(self): 508 | while not self.exit: 509 | time.sleep(0.1) 510 | if not self.opened_browser: 511 | continue 512 | 513 | if keyboard.is_pressed("1"): 514 | self.on_start_button_listener() 515 | elif keyboard.is_pressed("2"): 516 | self.on_stop_button_listener() 517 | 518 | def on_open_browser_button_listener(self): 519 | # Set Opening Browser button state to opening 520 | self.opening_browser = True 521 | self.open_browser_button["text"] = "Opening Browser..." 522 | self.open_browser_button["state"] = "disabled" 523 | self.open_browser_button.update() 524 | 525 | # Open Webdriver 526 | options = webdriver.ChromeOptions() 527 | options.add_experimental_option("excludeSwitches", ["enable-logging", "enable-automation"]) 528 | options.add_argument('--disable-blink-features=AutomationControlled') 529 | options.add_experimental_option('useAutomationExtension', False) 530 | try: 531 | chrome_install = ChromeDriverManager().install() 532 | 533 | folder = os.path.dirname(chrome_install) 534 | chromedriver_path = os.path.join(folder, "chromedriver.exe") 535 | 536 | service = ChromeService(chromedriver_path) 537 | self.chrome = webdriver.Chrome( 538 | service=service, 539 | options=options 540 | ) 541 | except WebDriverException: 542 | # No chrome installed 543 | self.opening_browser = False 544 | self.open_browser_button["text"] = "Open Browser" 545 | self.open_browser_button["state"] = "normal" 546 | self.open_browser_button.update() 547 | tk.messagebox.showerror( 548 | "Error", 549 | "Cant find Chrome. You need to have Chrome installed for this to work.", 550 | ) 551 | return 552 | except Exception as e: 553 | # Other error 554 | self.opening_browser = False 555 | self.open_browser_button["text"] = "Open Browser" 556 | self.open_browser_button["state"] = "normal" 557 | self.open_browser_button.update() 558 | tk.messagebox.showerror( 559 | "Error", 560 | f"An error occurred while opening the browser: {e}", 561 | ) 562 | return 563 | 564 | # Open chess.com 565 | if self.website.get() == "chesscom": 566 | self.chrome.get("https://www.chess.com") 567 | else: 568 | self.chrome.get("https://www.lichess.org") 569 | 570 | # Build Stockfish Bot 571 | self.chrome_url = self.chrome.service.service_url 572 | self.chrome_session_id = self.chrome.session_id 573 | 574 | # Set Opening Browser button state to opened 575 | self.opening_browser = False 576 | self.opened_browser = True 577 | self.open_browser_button["text"] = "Browser is open" 578 | self.open_browser_button["state"] = "disabled" 579 | self.open_browser_button.update() 580 | 581 | # Enable run button 582 | self.start_button["state"] = "normal" 583 | self.start_button.update() 584 | 585 | def on_start_button_listener(self): 586 | # Check if Slow mover value is valid 587 | slow_mover = self.slow_mover.get() 588 | if slow_mover < 10 or slow_mover > 1000: 589 | tk.messagebox.showerror( 590 | "Error", 591 | "Slow Mover must be between 10 and 1000" 592 | ) 593 | return 594 | 595 | # Check if stockfish path is not empty 596 | if self.stockfish_path == "": 597 | tk.messagebox.showerror( 598 | "Error", 599 | "Stockfish path is empty" 600 | ) 601 | return 602 | 603 | # Check if mouseless mode is enabled when on chess.com 604 | if self.enable_mouseless_mode.get() == 1 and self.website.get() == "chesscom": 605 | tk.messagebox.showerror( 606 | "Error", "Mouseless mode is only supported on lichess.org" 607 | ) 608 | return 609 | 610 | # Create the pipes used for the communication 611 | # between the GUI and the Stockfish Bot process 612 | parent_conn, child_conn = multiprocess.Pipe() 613 | self.stockfish_bot_pipe = parent_conn 614 | 615 | # Create the message queue that is used for the communication 616 | # between the Stockfish and the Overlay processes 617 | st_ov_queue = multiprocess.Queue() 618 | 619 | # Create the Stockfish Bot process 620 | self.stockfish_bot_process = StockfishBot( 621 | self.chrome_url, 622 | self.chrome_session_id, 623 | self.website.get(), 624 | child_conn, 625 | st_ov_queue, 626 | self.stockfish_path, 627 | self.enable_manual_mode.get() == 1, 628 | self.enable_mouseless_mode.get() == 1, 629 | self.enable_non_stop_puzzles.get() == 1, 630 | self.enable_non_stop_matches.get() == 1, 631 | self.mouse_latency.get(), 632 | self.enable_bongcloud.get() == 1, 633 | self.slow_mover.get(), 634 | self.skill_level.get(), 635 | self.stockfish_depth.get(), 636 | self.memory.get(), 637 | self.cpu_threads.get(), 638 | ) 639 | self.stockfish_bot_process.start() 640 | 641 | # Create the overlay 642 | self.overlay_screen_process = multiprocess.Process( 643 | target=run, args=(st_ov_queue,) 644 | ) 645 | self.overlay_screen_process.start() 646 | 647 | # Update the run button 648 | self.running = True 649 | self.start_button["text"] = "Starting..." 650 | self.start_button["state"] = "disabled" 651 | self.start_button.update() 652 | 653 | def on_stop_button_listener(self): 654 | # Stop the Stockfish Bot process 655 | if self.stockfish_bot_process is not None: 656 | if self.overlay_screen_process is not None: 657 | self.overlay_screen_process.kill() 658 | self.overlay_screen_process = None 659 | 660 | if self.stockfish_bot_process.is_alive(): 661 | self.stockfish_bot_process.kill() 662 | 663 | self.stockfish_bot_process = None 664 | 665 | # Close the Stockfish Bot pipe 666 | if self.stockfish_bot_pipe is not None: 667 | self.stockfish_bot_pipe.close() 668 | self.stockfish_bot_pipe = None 669 | 670 | # Update the status text 671 | self.running = False 672 | self.status_text["text"] = "Inactive" 673 | self.status_text["fg"] = "red" 674 | self.status_text.update() 675 | 676 | # Reset evaluation info 677 | self.eval_text["text"] = "-" 678 | self.eval_text["fg"] = "black" 679 | self.wdl_text["text"] = "-" 680 | self.material_text["text"] = "-" 681 | self.material_text["fg"] = "black" 682 | self.white_acc_text["text"] = "-" 683 | self.black_acc_text["text"] = "-" 684 | 685 | # Update the UI 686 | self.eval_text.update() 687 | self.wdl_text.update() 688 | self.material_text.update() 689 | self.white_acc_text.update() 690 | self.black_acc_text.update() 691 | 692 | # Update the run button 693 | if not self.restart_after_stopping: 694 | self.start_button["text"] = "Start" 695 | self.start_button["state"] = "normal" 696 | self.start_button["command"] = self.on_start_button_listener 697 | else: 698 | self.restart_after_stopping = False 699 | self.on_start_button_listener() 700 | self.start_button.update() 701 | 702 | def on_topmost_check_button_listener(self): 703 | if self.enable_topmost.get() == 1: 704 | self.master.attributes("-topmost", True) 705 | else: 706 | self.master.attributes("-topmost", False) 707 | 708 | def on_export_pgn_button_listener(self): 709 | # Create the file dialog 710 | f = filedialog.asksaveasfile( 711 | initialfile="match.pgn", 712 | defaultextension=".pgn", 713 | filetypes=[("Portable Game Notation", "*.pgn"), ("All Files", "*.*")], 714 | ) 715 | if f is None: 716 | return 717 | 718 | # Write the PGN to the file 719 | data = "" 720 | for i in range(len(self.match_moves) // 2 + 1): 721 | if len(self.match_moves) % 2 == 0 and i == len(self.match_moves) // 2: 722 | continue 723 | data += str(i + 1) + ". " 724 | data += self.match_moves[i * 2] + " " 725 | if (i * 2) + 1 < len(self.match_moves): 726 | data += self.match_moves[i * 2 + 1] + " " 727 | f.write(data) 728 | f.close() 729 | 730 | def on_select_stockfish_button_listener(self): 731 | # Create the file dialog 732 | f = filedialog.askopenfilename() 733 | if f is None: 734 | return 735 | 736 | # Set the Stockfish path 737 | self.stockfish_path = f 738 | self.stockfish_path_text["text"] = self.stockfish_path 739 | self.stockfish_path_text.update() 740 | 741 | # Clears the Treeview 742 | def clear_tree(self): 743 | self.tree.delete(*self.tree.get_children()) 744 | self.tree.update() 745 | 746 | # Inserts a move into the Treeview 747 | def insert_move(self, move): 748 | cells_num = sum( 749 | [len(self.tree.item(i)["values"]) - 1 for i in self.tree.get_children()] 750 | ) 751 | if (cells_num % 2) == 0: 752 | rows_num = len(self.tree.get_children()) 753 | self.tree.insert("", "end", text="1", values=(rows_num + 1, move)) 754 | else: 755 | self.tree.set(self.tree.get_children()[-1], column=2, value=move) 756 | self.tree.update() 757 | 758 | # Overwrites the Treeview with the given list of moves 759 | def set_moves(self, moves): 760 | self.clear_tree() 761 | 762 | # Insert in pairs 763 | pairs = list(zip(*[iter(moves)] * 2)) 764 | for i, pair in enumerate(pairs): 765 | self.tree.insert("", "end", text="1", values=(str(i + 1), pair[0], pair[1])) 766 | 767 | # Insert the remaining one if it exists 768 | if len(moves) % 2 == 1: 769 | self.tree.insert("", "end", text="1", values=(len(pairs) + 1, moves[-1])) 770 | 771 | self.tree.update() 772 | 773 | def on_manual_mode_checkbox_listener(self): 774 | if self.enable_manual_mode.get() == 1: 775 | self.manual_mode_frame.pack(after=self.manual_mode_checkbox) 776 | self.manual_mode_frame.update() 777 | else: 778 | self.manual_mode_frame.pack_forget() 779 | self.manual_mode_checkbox.update() 780 | 781 | def update_evaluation_display(self, eval_str, wdl_str, material_str, bot_acc, opponent_acc): 782 | self.eval_text["text"] = eval_str 783 | # Color based on eval (positive = green, negative = red) 784 | try: 785 | if eval_str.startswith("M"): # Mate 786 | mate_value = int(eval_str[1:]) 787 | self.eval_text["fg"] = "green" if mate_value > 0 else "red" 788 | else: # Centipawns 789 | eval_value = float(eval_str) 790 | self.eval_text["fg"] = "green" if eval_value > 0 else ("black" if eval_value == 0 else "red") 791 | except ValueError: 792 | self.eval_text["fg"] = "black" 793 | 794 | # Update WDL 795 | self.wdl_text["text"] = wdl_str 796 | 797 | # Update material 798 | self.material_text["text"] = material_str 799 | # Color based on material 800 | try: 801 | if material_str.startswith("+"): 802 | self.material_text["fg"] = "green" 803 | elif material_str.startswith("-"): 804 | self.material_text["fg"] = "red" 805 | else: 806 | self.material_text["fg"] = "black" 807 | except: 808 | self.material_text["fg"] = "black" 809 | 810 | # Update accuracy values 811 | self.white_acc_text["text"] = bot_acc 812 | self.black_acc_text["text"] = opponent_acc 813 | 814 | # Update the UI 815 | self.eval_text.update() 816 | self.wdl_text.update() 817 | self.material_text.update() 818 | self.white_acc_text.update() 819 | self.black_acc_text.update() 820 | 821 | 822 | if __name__ == "__main__": 823 | window = tk.Tk() 824 | my_gui = GUI(window) 825 | window.mainloop() 826 | --------------------------------------------------------------------------------