bots: Create Chess Bot.
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 <color> with computer` to start a game. 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. Use `bot_handler.storage` to preserve game state across messages. (@showell also did minor work here to have the test use verify_dialog() and have the bot respond to empty messages)
This commit is contained in:
parent
2fa677a3e0
commit
700ce6a673
|
@ -58,6 +58,8 @@ force_include = [
|
||||||
"zulip_bots/zulip_bots/bots/define/test_define.py",
|
"zulip_bots/zulip_bots/bots/define/test_define.py",
|
||||||
"zulip_bots/zulip_bots/bots/encrypt/encrypt.py",
|
"zulip_bots/zulip_bots/bots/encrypt/encrypt.py",
|
||||||
"zulip_bots/zulip_bots/bots/encrypt/test_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.")
|
parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.")
|
||||||
|
|
|
@ -52,7 +52,8 @@ setuptools_info = dict(
|
||||||
'html2text', # for bots/define
|
'html2text', # for bots/define
|
||||||
'BeautifulSoup4', # for bots/googlesearch
|
'BeautifulSoup4', # for bots/googlesearch
|
||||||
'lxml', # for bots/googlesearch
|
'lxml', # for bots/googlesearch
|
||||||
'requests' # for bots/link_shortener
|
'requests', # for bots/link_shortener
|
||||||
|
'python-chess[engine,gaviota]' # for bots/chess
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
0
zulip_bots/zulip_bots/bots/chess/__init__.py
Normal file
0
zulip_bots/zulip_bots/bots/chess/__init__.py
Normal file
2
zulip_bots/zulip_bots/bots/chess/chess.conf
Normal file
2
zulip_bots/zulip_bots/bots/chess/chess.conf
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[chess]
|
||||||
|
stockfish_location = <the location of Stockfish on this machine>
|
746
zulip_bots/zulip_bots/bots/chess/chess.py
Normal file
746
zulip_bots/zulip_bots/bots/chess/chess.py
Normal file
|
@ -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 (?P<user_color>white|black) with computer'
|
||||||
|
)
|
||||||
|
MOVE_REGEX = re.compile('do (?P<move_san>.+)$')
|
||||||
|
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 <color> 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 <your move>```\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)
|
39
zulip_bots/zulip_bots/bots/chess/doc.md
Normal file
39
zulip_bots/zulip_bots/bots/chess/doc.md
Normal file
|
@ -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 <white or black> with computer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playing
|
||||||
|
|
||||||
|
After starting the game, you can make your move by typing
|
||||||
|
|
||||||
|
```
|
||||||
|
do <your move>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.)
|
123
zulip_bots/zulip_bots/bots/chess/test_chess.py
Normal file
123
zulip_bots/zulip_bots/bots/chess/test_chess.py
Normal file
|
@ -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 <your move>```
|
||||||
|
|
||||||
|
*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 <your move>```
|
||||||
|
|
||||||
|
*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 <your move>```
|
||||||
|
|
||||||
|
*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),
|
||||||
|
])
|
Loading…
Reference in a new issue