import copy import re from typing import Any, Dict, Optional import chess import chess.uci from zulip_bots.lib import BotHandler START_REGEX = re.compile('start with other user$') START_COMPUTER_REGEX = re.compile('start as (?Pwhite|black) with computer') MOVE_REGEX = re.compile('do (?P.+)$') RESIGN_REGEX = re.compile('resign$') class ChessHandler: def usage(self) -> str: return ( 'Chess Bot is a bot that allows you to play chess against either ' 'another user or the computer. Use `start with other user` or ' '`start as with computer` to start a game.\n\n' 'In order to play against a computer, `chess.conf` must be set ' 'with the key `stockfish_location` set to the location of the ' 'Stockfish program on this computer.' ) def initialize(self, bot_handler: BotHandler) -> None: self.config_info = bot_handler.get_config_info('chess') try: self.engine = chess.uci.popen_engine(self.config_info['stockfish_location']) self.engine.uci() except FileNotFoundError: # It is helpful to allow for fake Stockfish locations if the bot # runner is testing or knows they won't be using an engine. print('That Stockfish doesn\'t exist. Continuing.') def handle_message(self, message: Dict[str, str], bot_handler: BotHandler) -> None: content = message['content'] if content == '': bot_handler.send_reply(message, self.usage()) return start_regex_match = START_REGEX.match(content) start_computer_regex_match = START_COMPUTER_REGEX.match(content) move_regex_match = MOVE_REGEX.match(content) resign_regex_match = RESIGN_REGEX.match(content) is_with_computer = False last_fen = chess.Board().fen() if bot_handler.storage.contains('is_with_computer'): is_with_computer = ( # `bot_handler`'s `storage` only accepts `str` values. bot_handler.storage.get('is_with_computer') == str(True) ) if bot_handler.storage.contains('last_fen'): last_fen = bot_handler.storage.get('last_fen') if start_regex_match: self.start(message, bot_handler) elif start_computer_regex_match: self.start_computer( message, bot_handler, start_computer_regex_match.group('user_color') == 'white' ) elif move_regex_match: if is_with_computer: self.move_computer( message, bot_handler, last_fen, move_regex_match.group('move_san') ) else: self.move(message, bot_handler, last_fen, move_regex_match.group('move_san')) elif resign_regex_match: self.resign(message, bot_handler, last_fen) def start(self, message: Dict[str, str], bot_handler: BotHandler) -> None: """Starts a game with another user, with the current user as white. Replies to the bot handler. Parameters: - message: The Zulip Bots message object. - bot_handler: The Zulip Bots bot handler object. """ new_board = chess.Board() bot_handler.send_reply(message, make_start_reponse(new_board)) # `bot_handler`'s `storage` only accepts `str` values. bot_handler.storage.put('is_with_computer', str(False)) bot_handler.storage.put('last_fen', new_board.fen()) def start_computer( self, message: Dict[str, str], bot_handler: BotHandler, is_white_user: bool ) -> None: """Starts a game with the computer. Replies to the bot handler. Parameters: - message: The Zulip Bots message object. - bot_handler: The Zulip Bots bot handler object. - is_white_user: Whether or not the player wants to be white. If false, the user is black. If the user is white, they will get to make the first move; if they are black the computer will make the first move. """ new_board = chess.Board() if is_white_user: bot_handler.send_reply(message, make_start_computer_reponse(new_board)) # `bot_handler`'s `storage` only accepts `str` values. bot_handler.storage.put('is_with_computer', str(True)) bot_handler.storage.put('last_fen', new_board.fen()) else: self.move_computer_first( message, bot_handler, new_board.fen(), ) def validate_board( self, message: Dict[str, str], bot_handler: BotHandler, fen: str ) -> Optional[chess.Board]: """Validates a board based on its FEN string. Replies to the bot handler if there is an error with the board. Parameters: - message: The Zulip Bots message object. - bot_handler: The Zulip Bots bot handler object. - fen: The FEN string of the board. Returns: `None` if the board didn't pass, or the board object itself if it did. """ try: last_board = chess.Board(fen) except ValueError: bot_handler.send_reply(message, make_copied_wrong_response()) return None return last_board def validate_move( self, message: Dict[str, str], bot_handler: BotHandler, last_board: chess.Board, move_san: str, is_computer: object, ) -> Optional[chess.Move]: """Validates a move based on its SAN string and the current board. Replies to the bot handler if there is an error with the move. Parameters: - message: The Zulip Bots message object. - bot_handler: The Zulip Bots bot handler object. - last_board: The board object before the move. - move_san: The SAN of the move. - is_computer: Whether or not the user is playing against a computer (used in the response if the move is not legal). Returns: `False` if the move didn't pass, or the move object itself if it did. """ try: move = last_board.parse_san(move_san) except ValueError: bot_handler.send_reply(message, make_not_legal_response(last_board, move_san)) return None if move not in last_board.legal_moves: bot_handler.send_reply(message, make_not_legal_response(last_board, move_san)) return None return move def check_game_over( self, message: Dict[str, str], bot_handler: BotHandler, new_board: chess.Board ) -> bool: """Checks if a game is over due to - checkmate, - stalemate, - insufficient material, - 50 moves without a capture or pawn move, or - 3-fold repetition. Replies to the bot handler if it is game over. Parameters: - message: The Zulip Bots message object. - bot_handler: The Zulip Bots bot handler object. - new_board: The board object. Returns: True if it is game over, false if it's not. """ # This assumes that the players will claim a draw after 3-fold # repetition or 50 moves go by without a capture or pawn move. # According to the official rules, the game is only guaranteed to # be over if it's *5*-fold or *75* moves, but if either player # wants the game to be a draw, after 3 or 75 it a draw. For now, # just assume that the players would want the draw. if new_board.is_game_over(True): game_over_output = '' if new_board.is_checkmate(): game_over_output = make_loss_response(new_board, 'was checkmated') elif new_board.is_stalemate(): game_over_output = make_draw_response('stalemate') elif new_board.is_insufficient_material(): game_over_output = make_draw_response('insufficient material') elif new_board.can_claim_fifty_moves(): game_over_output = make_draw_response('50 moves without a capture or pawn move') elif new_board.can_claim_threefold_repetition(): game_over_output = make_draw_response('3-fold repetition') bot_handler.send_reply(message, game_over_output) return True return False def move( self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str, move_san: str ) -> None: """Makes a move for a user in a game with another user. Replies to the bot handler. Parameters: - message: The Zulip Bots message object. - bot_handler: The Zulip Bots bot handler object. - last_fen: The FEN string of the board before the move. - move_san: The SAN of the move to make. """ last_board = self.validate_board(message, bot_handler, last_fen) if not last_board: return move = self.validate_move(message, bot_handler, last_board, move_san, False) if not move: return new_board = copy.copy(last_board) new_board.push(move) if self.check_game_over(message, bot_handler, new_board): return bot_handler.send_reply(message, make_move_reponse(last_board, new_board, move)) bot_handler.storage.put('last_fen', new_board.fen()) def move_computer( self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str, move_san: str ) -> None: """Preforms a move for a user in a game with the computer and then makes the computer's move. Replies to the bot handler. Unlike `move`, replies only once to the bot handler every two moves (only after the computer moves) instead of after every move. Doesn't require a call in order to make the computer move. To make the computer move without the user going first, use `move_computer_first`. Parameters: - message: The Zulip Bots message object. - bot_handler: The Zulip Bots bot handler object. - last_fen: The FEN string of the board before the user's move. - move_san: The SAN of the user's move to make. """ last_board = self.validate_board(message, bot_handler, last_fen) if not last_board: return move = self.validate_move(message, bot_handler, last_board, move_san, True) if not move: return new_board = copy.copy(last_board) new_board.push(move) if self.check_game_over(message, bot_handler, new_board): return computer_move = calculate_computer_move(new_board, self.engine) new_board_after_computer_move = copy.copy(new_board) new_board_after_computer_move.push(computer_move) if self.check_game_over(message, bot_handler, new_board_after_computer_move): return bot_handler.send_reply( message, make_move_reponse(new_board, new_board_after_computer_move, computer_move) ) bot_handler.storage.put('last_fen', new_board_after_computer_move.fen()) def move_computer_first( self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str ) -> None: """Preforms a move for the computer without having the user go first in a game with the computer. Replies to the bot handler. Like `move_computer`, but doesn't have the user move first. This is usually only useful at the beginning of a game. Parameters: - message: The Zulip Bots message object. - bot_handler: The Zulip Bots bot handler object. - last_fen: The FEN string of the board before the computer's move. """ last_board = self.validate_board(message, bot_handler, last_fen) computer_move = calculate_computer_move(last_board, self.engine) new_board_after_computer_move = copy.copy(last_board) # type: chess.Board new_board_after_computer_move.push(computer_move) if self.check_game_over(message, bot_handler, new_board_after_computer_move): return bot_handler.send_reply( message, make_move_reponse(last_board, new_board_after_computer_move, computer_move) ) bot_handler.storage.put('last_fen', new_board_after_computer_move.fen()) # `bot_handler`'s `storage` only accepts `str` values. bot_handler.storage.put('is_with_computer', str(True)) def resign(self, message: Dict[str, str], bot_handler: BotHandler, last_fen: str) -> None: """Resigns the game for the current player. Parameters: - message: The Zulip Bots message object. - bot_handler: The Zulip Bots bot handler object. - last_fen: The FEN string of the board. """ last_board = self.validate_board(message, bot_handler, last_fen) if not last_board: return bot_handler.send_reply(message, make_loss_response(last_board, 'resigned')) handler_class = ChessHandler def calculate_computer_move(board: chess.Board, engine: Any) -> chess.Move: """Calculates the computer's move. Parameters: - board: The board object before the move. - engine: The UCI engine object. Returns: The computer's move object. """ engine.position(board) best_move_and_ponder_move = engine.go(movetime=(3000)) return best_move_and_ponder_move[0] def make_draw_response(reason: str) -> str: """Makes a response string for a draw. Parameters: - reason: The reason for the draw, in the form of a noun, e.g., 'stalemate' or 'insufficient material'. Returns: The draw response string. """ return 'It\'s a draw because of {}!'.format(reason) def make_loss_response(board: chess.Board, reason: str) -> str: """Makes a response string for a loss (or win). Parameters: - board: The board object at the end of the game. - reason: The reason for the loss, in the form of a predicate, e.g., 'was checkmated'. Returns: The loss response string. """ return ('*{}* {}. **{}** wins!\n\n' '{}').format( 'White' if board.turn else 'Black', reason, 'Black' if board.turn else 'White', make_str(board, board.turn), ) def make_not_legal_response(board: chess.Board, move_san: str) -> str: """Makes a response string for a not-legal move. Parameters: - board: The board object before the move. - move_san: The SAN of the not-legal move. Returns: The not-legal-move response string. """ return ('Sorry, the move *{}* isn\'t legal.\n\n' '{}' '\n\n\n' '{}').format( move_san, make_str(board, board.turn), make_footer() ) def make_copied_wrong_response() -> str: """Makes a response string for a FEN string that was copied wrong. Returns: The copied-wrong response string. """ return ( 'Sorry, it seems like you copied down the response wrong.\n\n' 'Please try to copy the response again from the last message!' ) def make_start_reponse(board: chess.Board) -> str: """Makes a response string for the first response of a game with another user. Parameters: - board: The board object to start the game with (which most-likely should be general opening chess position). Returns: The starting response string. """ return ( 'New game! The board looks like this:\n\n' '{}' '\n\n\n' 'Now it\'s **{}**\'s turn.' '\n\n\n' '{}' ).format(make_str(board, True), 'white' if board.turn else 'black', make_footer()) def make_start_computer_reponse(board: chess.Board) -> str: """Makes a response string for the first response of a game with a computer, when the user is playing as white. If the user is playing as black, use `ChessHandler.move_computer_first`. Parameters: - board: The board object to start the game with (which most-likely should be general opening chess position). Returns: The starting response string. """ return ( 'New game with computer! The board looks like this:\n\n' '{}' '\n\n\n' 'Now it\'s **{}**\'s turn.' '\n\n\n' '{}' ).format(make_str(board, True), 'white' if board.turn else 'black', make_footer()) def make_move_reponse(last_board: chess.Board, new_board: chess.Board, move: chess.Move) -> str: """Makes a response string for after a move is made. Parameters: - last_board: The board object before the move. - new_board: The board object after the move. - move: The move object. Returns: The move response string. """ return ( 'The board was like this:\n\n' '{}' '\n\n\n' 'Then *{}* moved *{}*:\n\n' '{}' '\n\n\n' 'Now it\'s **{}**\'s turn.' '\n\n\n' '{}' ).format( make_str(last_board, new_board.turn), 'white' if last_board.turn else 'black', last_board.san(move), make_str(new_board, new_board.turn), 'white' if new_board.turn else 'black', make_footer(), ) def make_footer() -> str: """Makes a footer to be appended to the bottom of other, actionable responses. """ return ( 'To make your next move, respond to Chess Bot with\n\n' '```do ```\n\n' '*Remember to @-mention Chess Bot at the beginning of your ' 'response.*' ) def make_str(board: chess.Board, is_white_on_bottom: bool) -> str: """Converts a board object into a string to be used in Markdown. Backticks are added around the string to preserve formatting. Parameters: - board: The board object. - is_white_on_bottom: Whether or not white should be on the bottom side in the string. If false, black will be on the bottom. Returns: The string made from the board. """ default_str = board.__str__() replaced_str = replace_with_unicode(default_str) replaced_and_guided_str = guide_with_numbers(replaced_str) properly_flipped_str = ( replaced_and_guided_str if is_white_on_bottom else replaced_and_guided_str[::-1] ) trimmed_str = trim_whitespace_before_newline(properly_flipped_str) monospaced_str = '```\n{}\n```'.format(trimmed_str) return monospaced_str def guide_with_numbers(board_str: str) -> str: """Adds numbers and letters on the side of a string without them made out of a board. Parameters: - board_str: The string from the board object. Returns: The string with the numbers and letters. """ # Spaces and newlines would mess up the loop because they add extra indexes # between pieces. Newlines are added later by the loop and spaces are added # back in at the end. board_without_whitespace_str = board_str.replace(' ', '').replace('\n', '') # The first number, 8, needs to be added first because it comes before a # newline. From then on, numbers are inserted at newlines. row_list = list('8' + board_without_whitespace_str) for i, char in enumerate(row_list): # `(i + 1) % 10 == 0` if it is the end of a row, i.e., the 10th column # since lists are 0-indexed. if (i + 1) % 10 == 0: # Since `i + 1` is always a multiple of 10 (because index 0, 10, # 20, etc. is the other row letter and 1-8, 11-18, 21-28, etc. are # the squares), `(i + 1) // 10` is the inverted row number (1 when # it should be 8, 2 when it should be 7, etc.), so therefore # `9 - (i + 1) // 10` is the actual row number. row_num = 9 - (i + 1) // 10 # The 3 separate components are split into only 2 elements so that # the newline isn't counted by the loop. If they were split into 3, # or combined into just 1 string, the counter would become off # because it would be counting what is really 2 rows as 3 or 1. row_list[i:i] = [str(row_num) + '\n', str(row_num - 1)] # 1 is appended to the end because it isn't created in the loop, and lines # that begin with spaces have their spaces removed for aesthetics. row_str = (' '.join(row_list) + ' 1').replace('\n ', '\n') # a, b, c, d, e, f, g, and h are easy to add in. row_and_col_str = ' a b c d e f g h \n' + row_str + '\n a b c d e f g h ' return row_and_col_str def replace_with_unicode(board_str: str) -> str: """Replaces the default characters in a board object's string output with Unicode chess characters, e.g., '♖' instead of 'R.' Parameters: - board_str: The string from the board object. Returns: The string with the replaced characters. """ replaced_str = board_str replaced_str = replaced_str.replace('P', '♙') replaced_str = replaced_str.replace('N', '♘') replaced_str = replaced_str.replace('B', '♗') replaced_str = replaced_str.replace('R', '♖') replaced_str = replaced_str.replace('Q', '♕') replaced_str = replaced_str.replace('K', '♔') replaced_str = replaced_str.replace('p', '♟') replaced_str = replaced_str.replace('n', '♞') replaced_str = replaced_str.replace('b', '♝') replaced_str = replaced_str.replace('r', '♜') replaced_str = replaced_str.replace('q', '♛') replaced_str = replaced_str.replace('k', '♚') replaced_str = replaced_str.replace('.', '·') return replaced_str def trim_whitespace_before_newline(str_to_trim: str) -> str: """Removes any spaces before a newline in a string. Parameters: - str_to_trim: The string to trim. Returns: The trimmed string. """ return re.sub(r'\s+$', '', str_to_trim, flags=re.M)