diff --git a/tools/run-mypy b/tools/run-mypy index 81b781c..6fbab1d 100755 --- a/tools/run-mypy +++ b/tools/run-mypy @@ -58,6 +58,8 @@ force_include = [ "zulip_bots/zulip_bots/bots/define/test_define.py", "zulip_bots/zulip_bots/bots/encrypt/encrypt.py", "zulip_bots/zulip_bots/bots/encrypt/test_encrypt.py", + "zulip_bots/zulip_bots/bots/chess/chess.py", + "zulip_bots/zulip_bots/bots/chess/test_chess.py", ] parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.") diff --git a/zulip_bots/setup.py b/zulip_bots/setup.py index 3026fbc..f372aa8 100755 --- a/zulip_bots/setup.py +++ b/zulip_bots/setup.py @@ -52,7 +52,8 @@ setuptools_info = dict( 'html2text', # for bots/define 'BeautifulSoup4', # for bots/googlesearch 'lxml', # for bots/googlesearch - 'requests' # for bots/link_shortener + 'requests', # for bots/link_shortener + 'python-chess[engine,gaviota]' # for bots/chess ], ) diff --git a/zulip_bots/zulip_bots/bots/chess/__init__.py b/zulip_bots/zulip_bots/bots/chess/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zulip_bots/zulip_bots/bots/chess/chess.conf b/zulip_bots/zulip_bots/bots/chess/chess.conf new file mode 100644 index 0000000..156bf07 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/chess/chess.conf @@ -0,0 +1,2 @@ +[chess] +stockfish_location = diff --git a/zulip_bots/zulip_bots/bots/chess/chess.py b/zulip_bots/zulip_bots/bots/chess/chess.py new file mode 100644 index 0000000..2cd4ffa --- /dev/null +++ b/zulip_bots/zulip_bots/bots/chess/chess.py @@ -0,0 +1,746 @@ +import chess +import chess.uci +import re +import copy +from typing import Any, Optional +from zulip_bots.lib import ExternalBotHandler + +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(object): + 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: ExternalBotHandler) -> 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, + bot_handler: ExternalBotHandler + ) -> 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, bot_handler: ExternalBotHandler) -> 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, + bot_handler: ExternalBotHandler, + 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, + bot_handler: ExternalBotHandler, + 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: `False` 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 False + + return last_board + + def validate_move( + self, + message: dict, + bot_handler: ExternalBotHandler, + 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, + bot_handler: ExternalBotHandler, + 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, + bot_handler: ExternalBotHandler, + 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, + bot_handler: ExternalBotHandler, + 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, + bot_handler: ExternalBotHandler, + 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) + 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, + bot_handler: ExternalBotHandler, + 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('\s+$', '', str_to_trim, flags=re.M) diff --git a/zulip_bots/zulip_bots/bots/chess/doc.md b/zulip_bots/zulip_bots/bots/chess/doc.md new file mode 100644 index 0000000..6d73cb2 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/chess/doc.md @@ -0,0 +1,39 @@ +## Starting a Game + +You can start a game with another user by typing + +``` +start with other user +``` + +or you can start a game with a computer with + +``` +start as with computer +``` + +## Playing + +After starting the game, you can make your move by typing + +``` +do +``` + +using [Standard Algebraic Chess Notation](https://goo.gl/rehi8n). For example, +`do e4` to move a pawn to *e4* or `do Nf3` to move a night to *f3* or `do O-O` +to castle. + + +## Ending the game + +The bot will detect if a game is over. You can end one early by resigning +with the + +``` +resign +``` + +command. + +(Or you could just stop responding.) diff --git a/zulip_bots/zulip_bots/bots/chess/test_chess.py b/zulip_bots/zulip_bots/bots/chess/test_chess.py new file mode 100644 index 0000000..77d1e4b --- /dev/null +++ b/zulip_bots/zulip_bots/bots/chess/test_chess.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python + +from zulip_bots.test_lib import StubBotTestCase + +class TestChessBot(StubBotTestCase): + bot_name = "chess" + + START_RESPONSE = '''New game! The board looks like this: + +``` + a b c d e f g h +8 ♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜ 8 +7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7 +6 · · · · · · · · 6 +5 · · · · · · · · 5 +4 · · · · · · · · 4 +3 · · · · · · · · 3 +2 ♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙ 2 +1 ♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖ 1 + a b c d e f g h +``` + + +Now it's **white**'s turn. + + +To make your next move, respond to Chess Bot with + +```do ``` + +*Remember to @-mention Chess Bot at the beginning of your response.*''' + + DO_E4_RESPONSE = '''The board was like this: + +``` + h g f e d c b a +1 ♖ ♘ ♗ ♔ ♕ ♗ ♘ ♖ 1 +2 ♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙ 2 +3 · · · · · · · · 3 +4 · · · · · · · · 4 +5 · · · · · · · · 5 +6 · · · · · · · · 6 +7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7 +8 ♜ ♞ ♝ ♚ ♛ ♝ ♞ ♜ 8 + h g f e d c b a +``` + + +Then *white* moved *e4*: + +``` + h g f e d c b a +1 ♖ ♘ ♗ ♔ ♕ ♗ ♘ ♖ 1 +2 ♙ ♙ ♙ · ♙ ♙ ♙ ♙ 2 +3 · · · · · · · · 3 +4 · · · ♙ · · · · 4 +5 · · · · · · · · 5 +6 · · · · · · · · 6 +7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7 +8 ♜ ♞ ♝ ♚ ♛ ♝ ♞ ♜ 8 + h g f e d c b a +``` + + +Now it's **black**'s turn. + + +To make your next move, respond to Chess Bot with + +```do ``` + +*Remember to @-mention Chess Bot at the beginning of your response.*''' + + DO_KE4_RESPONSE = '''Sorry, the move *Ke4* isn't legal. + +``` + h g f e d c b a +1 ♖ ♘ ♗ ♔ ♕ ♗ ♘ ♖ 1 +2 ♙ ♙ ♙ · ♙ ♙ ♙ ♙ 2 +3 · · · · · · · · 3 +4 · · · ♙ · · · · 4 +5 · · · · · · · · 5 +6 · · · · · · · · 6 +7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7 +8 ♜ ♞ ♝ ♚ ♛ ♝ ♞ ♜ 8 + h g f e d c b a +``` + + +To make your next move, respond to Chess Bot with + +```do ``` + +*Remember to @-mention Chess Bot at the beginning of your response.*''' + + RESIGN_RESPONSE = '''*Black* resigned. **White** wins! + +``` + h g f e d c b a +1 ♖ ♘ ♗ ♔ ♕ ♗ ♘ ♖ 1 +2 ♙ ♙ ♙ · ♙ ♙ ♙ ♙ 2 +3 · · · · · · · · 3 +4 · · · ♙ · · · · 4 +5 · · · · · · · · 5 +6 · · · · · · · · 6 +7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7 +8 ♜ ♞ ♝ ♚ ♛ ♝ ♞ ♜ 8 + h g f e d c b a +```''' + + def test_bot_responds_to_empty_message(self) -> None: + with self.mock_config_info({'stockfish_location': '/foo/bar'}): + response = self.get_response(dict(content='')) + self.assertIn('play chess', response['content']) + + def test_main(self) -> None: + with self.mock_config_info({'stockfish_location': '/foo/bar'}): + self.verify_dialog([ + ('start with other user', self.START_RESPONSE), + ('do e4', self.DO_E4_RESPONSE), + ('do Ke4', self.DO_KE4_RESPONSE), + ('resign', self.RESIGN_RESPONSE), + ])