diff --git a/zulip_bots/zulip_bots/bots/connect_four/__init__.py b/zulip_bots/zulip_bots/bots/connect_four/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zulip_bots/zulip_bots/bots/connect_four/connect_four.py b/zulip_bots/zulip_bots/bots/connect_four/connect_four.py new file mode 100644 index 0000000..c699230 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/connect_four/connect_four.py @@ -0,0 +1,535 @@ +# @TODO: place bot owner name in config file, allow bot owner to run special commands + +import re +from copy import deepcopy +from zulip_bots.bots.connect_four.controller import ConnectFourModel + +class InputVerification(object): + verified_users = [] + + all_valid_commands = ['help', 'status', 'start game with computer', 'start game with \w+@\w+\.\w+', + 'withdraw invitation', 'accept', 'decline', 'move \d$', 'quit', 'confirm quit'] + + # Every command that can be run, in states requiring user verification, by each player + verified_commands = { + 'waiting': ['start game with computer', 'start game with \w+@\w+\.\w+'], + 'inviting': [['withdraw invitation'], ['accept', 'decline']], + 'playing': [['move \d$', 'quit', 'confirm quit'], ['quit', 'confirm quit']] + } + + def permission_lacking_message(self, command): + return 'Sorry, but you can\'t run the command ```' + command + '```' + + def update_commands(self, turn): + self.verified_commands['playing'][-1 * turn + 1].remove('move \d$') + self.verified_commands['playing'][turn].append('move \d$') + + def reset_commands(self): + self.verified_commands['playing'] = [['move \d$', 'quit', 'confirm quit'], ['quit', 'confirm quit']] + + def regex_match_in_array(self, command_array, command): + for command_regex in command_array: + if re.compile(command_regex).match(command.lower()): + return True + + return False + + def valid_command(self, command): + return self.regex_match_in_array(self.all_valid_commands, command) + + def verify_user(self, user): + return user in self.verified_users + + def verify_command(self, user, command, state): + if state != 'waiting': + command_array = self.verified_commands[state][self.verified_users.index(user)] + else: + command_array = self.verified_commands[state] + + return self.regex_match_in_array(command_array, command) + +class StateManager(object): + def __init__(self, main_bot_handler): + self.users = None + self.state = '' + self.user_messages = [] + self.opponent_messages = [] + self.main_bot_handler = main_bot_handler + + # Updates to the main bot handler that all state managers must use + def basic_updates(self): + if self.users is not None: + self.main_bot_handler.inputVerification.verified_users = self.users + + if self.state: + self.main_bot_handler.state = self.state + + self.main_bot_handler.user_messages = self.user_messages + + self.main_bot_handler.opponent_messages = self.opponent_messages + + def reset_self(self): + self.users = None + self.user_messages = [] + self.opponent_messages = [] + self.state = '' + +class GameCreator(StateManager): + def __init__(self, main_bot_handler): + super(GameCreator, self).__init__(main_bot_handler) + self.gameHandler = None + self.invitationHandler = None + + def handle_message(self, content, sender): + if content == 'start game with computer': + self.users = [sender] + self.state = 'playing' + self.gameHandler = GameHandler(self.main_bot_handler, 'one_player') + + self.user_messages.append('**You started a new game with the computer!**') + self.user_messages.append(self.gameHandler.parse_board()) + self.user_messages.append(self.gameHandler.your_turn_message()) + + elif re.compile('\w+@\w+\.\w+').search(content): + opponent = re.compile('(\w+@\w+\.\w+)').search(content).group(1) + + if opponent == sender: + self.user_messages.append('You can\'t play against yourself!') + self.update_main_bot_handler() + return + + self.users = [sender, opponent] + self.state = 'inviting' + self.gameHandler = GameHandler(self.main_bot_handler, 'two_player') + self.invitationHandler = InvitationHandler(self.main_bot_handler) + + self.user_messages.append(self.invitationHandler.confirm_new_invitation(opponent)) + + self.opponent_messages.append(self.invitationHandler.alert_new_invitation(sender)) + + self.update_main_bot_handler() + + def update_main_bot_handler(self): + self.basic_updates() + + self.main_bot_handler.player_cache = self.users + + self.main_bot_handler.gameHandler = deepcopy(self.gameHandler) + + if self.invitationHandler: + self.main_bot_handler.invitationHandler = deepcopy(self.invitationHandler) + + self.reset_self() + +class GameHandler(StateManager): + def __init__(self, main_bot_handler, game_type, board = ConnectFourModel().blank_board, turn = 0): + super(GameHandler, self).__init__(main_bot_handler) + self.game_type = game_type + self.board = board + self.turn = turn + self.game_ended = False + self.connectFourModel = ConnectFourModel() + self.connectFourModel.update_board(board) + self.tokens = [':blue_circle:', ':red_circle:'] + + def parse_board(self): + # Header for the top of the board + board_str = ':one: :two: :three: :four: :five: :six: :seven:' + + for row in range(0, 6): + board_str += '\n\n' + for column in range(0, 7): + if self.board[row][column] == 0: + board_str += ':heavy_large_circle: ' + elif self.board[row][column] == 1: + board_str += ':blue_circle: ' + elif self.board[row][column] == -1: + board_str += ':red_circle: ' + + return board_str + + def your_turn_message(self): + return '**It\'s your move!**\n' +\ + 'type ```move ``` to make your move\n\n' +\ + 'You are ' + self.tokens[self.turn] + + def wait_turn_message(self, opponent): + return 'Waiting for ' + opponent + ' to move' + + def alert_move_message(self, original_player, column_number): + return '**' + original_player + ' moved in column ' + str(column_number + 1) + '**.' + + def append_game_over_messages(self, result): + if result == 'draw': + self.user_messages.append('**It\'s a draw!**') + self.opponent_messages.append('**It\'s a draw!**') + else: + if result != 'the Computer': + self.user_messages.append('**Congratulations, you win! :tada:**') + self.opponent_messages.append('Sorry, but ' + result + ' won :cry:') + else: + self.user_messages.append('Sorry, but ' + result + ' won :cry:') + + def get_player_token(self, sender): + player = self.main_bot_handler.inputVerification.verified_users.index(sender) + # This computation will return 1 for player 0, and -1 for player 1, as is expected + return (-2) * player + 1 + + def toggle_turn(self): + self.turn = (-1) * self.turn + 1 + + def end_game(self): + self.state = 'waiting' + self.game_ended = True + self.users = [] + + def handle_move(self, column_number, token_number, player_one, player_two, computer_play = False): + if not self.connectFourModel.validate_move(column_number): + self.user_messages.append('That\'s an invalid move. Please specify a column' + + ' with at least one blank space, between 1 and 7') + return + + self.board = self.connectFourModel.make_move(column_number, token_number) + + if not computer_play: + self.user_messages.append('You placed your token in column ' + str(column_number + 1) + '.') + self.user_messages.append(self.parse_board()) + + self.opponent_messages.append(self.alert_move_message(self.sender, column_number)) + self.opponent_messages.append(self.parse_board()) + + else: + self.user_messages.append(self.alert_move_message('the Computer', column_number)) + self.user_messages.append(self.parse_board()) + + game_over = self.connectFourModel.determine_game_over(player_one, player_two) + + if game_over: + self.append_game_over_messages(game_over) + self.end_game() + + else: + self.toggle_turn() + + self.main_bot_handler.inputVerification.update_commands(self.turn) + + if not computer_play: + self.user_messages.append(self.wait_turn_message(self.opponent)) + + self.opponent_messages.append(self.your_turn_message()) + + else: + self.user_messages.append(self.your_turn_message()) + + def handle_message(self, content, sender): + self.sender = sender + + if self.game_type == 'two_player': + opponent_array = deepcopy(self.main_bot_handler.inputVerification.verified_users) + opponent_array.remove(sender) + self.opponent = opponent_array[0] + else: + self.opponent = 'the Computer' + + if content == 'quit': + self.user_messages.append('Are you sure you want to quit? You will forfeit the game!\n' + + 'Type ```confirm quit``` to forfeit.') + + elif content == 'confirm quit': + self.end_game() + + self.user_messages.append('**You have forfeit the game**\nSorry, but you lost :cry:') + + self.opponent_messages.append('**' + sender + ' has forfeit the game**\nCongratulations, you win! :tada:') + + elif re.compile('move \d$').match(content): + player_one = player_one = self.main_bot_handler.inputVerification.verified_users[0] + player_two = 'the Computer' if self.game_type == 'one_player' else self.main_bot_handler.inputVerification.verified_users[1] + + human_move = int(re.compile('move (\d)$').search(content).group(1)) - 1 + human_token_number = self.get_player_token(sender) + + self.handle_move(human_move, human_token_number, player_one, player_two) + + if not self.game_ended and self.game_type == 'one_player': + computer_move = self.connectFourModel.computer_move() + computer_token_number = -1 + + self.handle_move(computer_move, computer_token_number, player_one, player_two, computer_play = True) + + self.update_main_bot_handler() + + def update_main_bot_handler(self): + if self.game_type == 'one_player': + self.opponent_messages = [] + + self.basic_updates() + + if self.game_ended: + self.main_bot_handler.gameHandler = None + + self.reset_self() + +class InvitationHandler(StateManager): + def __init__(self, main_bot_handler): + super(InvitationHandler, self).__init__(main_bot_handler) + self.game_cancelled = False + self.gameHandler = object + + def confirm_new_invitation(self, opponent): + return 'You\'ve sent an invitation to play Connect Four with ' +\ + opponent + '. I\'ll let you know when they respond to the invitation' + + def alert_new_invitation(self, challenger): + # Since the first player invites, the challenger is always the first player + return '**' + challenger + ' has invited you to play a game of Connect Four.**\n' +\ + 'Type ```accept``` to accept the game invitation\n' +\ + 'Type ```decline``` to decline the game invitation.' + + def handle_message(self, content, sender): + challenger = self.main_bot_handler.inputVerification.verified_users[0] + opponent = self.main_bot_handler.inputVerification.verified_users[1] + + if content.lower() == 'accept': + self.state = 'playing' + + self.user_messages.append('You accepted the invitation to play with ' + challenger) + self.user_messages.append(self.main_bot_handler.gameHandler.wait_turn_message(challenger)) + + self.opponent_messages.append('**' + opponent + ' has accepted your invitation to play**') + self.opponent_messages.append(self.main_bot_handler.gameHandler.parse_board()) + self.opponent_messages.append(self.main_bot_handler.gameHandler.your_turn_message()) + + elif content.lower() == 'decline': + self.state = 'waiting' + self.users = [] + self.gameHandler = None + + self.user_messages.append('You declined the invitation to play with ' + challenger) + + self.opponent_messages.append('**' + opponent + ' has declined your invitation to play**\n' + + 'Invite another player by typing ```start game with user@example.com```') + + elif content.lower() == 'withdraw invitation': + self.state = 'waiting' + self.users = [] + self.gameHandler = None + + self.user_messages.append('Your invitation to play ' + opponent + ' has been withdrawn') + + self.opponent_messages.append('**' + challenger + ' has withdrawn his invitation to play you**\n' + + 'Type ``` start game with ' + challenger + '``` if you would like to play them.') + + self.update_main_bot_handler() + + def update_main_bot_handler(self): + self.basic_updates() + + self.main_bot_handler.invitationHandler = None + + if self.gameHandler is None: + self.main_bot_handler.gameHandler = self.gameHandler + + self.reset_self() + +class ConnectFourBotHandler(object): + ''' + Bot that allows users to player another user + or the computer in a game of Connect Four + ''' + + def get_stored_data(self): + # return self.data # Uncomment and rerun bot to reset data if users are abusing the bot + return self.bot_handler.storage.get('connect_four') + + def update_data(self): + self.state = self.data['state'] + + if 'users' in self.data: + self.inputVerification.verified_users = self.data['users'] + + if self.state == 'inviting': + self.invitationHandler = InvitationHandler(self) + self.gameHandler = GameHandler(self, self.data['game_type']) + + elif self.state == 'playing': + self.gameHandler = GameHandler(self, self.data['game_type'], board = self.data['board'], turn = self.data['turn']) + + def put_stored_data(self): + self.data = {} + + self.data['state'] = self.state + + if self.inputVerification.verified_users: + self.data['users'] = self.inputVerification.verified_users + + if self.state == 'inviting': + self.data['game_type'] = self.gameHandler.game_type + + elif self.state == 'playing': + self.data['game_type'] = self.gameHandler.game_type + self.data['board'] = self.gameHandler.board + self.data['turn'] = self.gameHandler.turn + + self.bot_handler.storage.put('connect_four', self.data) + + # Stores the current state of the game. Either 'waiting 'inviting' or 'playing' + state = 'waiting' + + # Stores the users, in case one of the state managers modifies the verified users + player_cache = [] + + # Object-wide storage to the bot_handler to allow custom message-sending function + bot_handler = None + + inputVerification = InputVerification() + invitationHandler = None + gameHandler = None + gameCreator = None + + user_messages = [] + opponent_messages = [] + + # Stores a compact version of all data the bot is managing + data = {'state': 'waiting'} + + def status_message(self): + prefix = '**Connect Four Game Status**\n' +\ + '*If you suspect users are abusing the bot,' +\ + ' please alert the bot owner*\n\n' + + if self.state == 'playing': + if self.gameHandler.game_type == 'one_player': + message = 'The bot is currently running a single player game' +\ + ' for ' + self.inputVerification.verified_users[0] + '.' + + elif self.gameHandler.game_type == 'two_player': + message = 'The bot is currently running a two player game ' +\ + 'between ' + self.inputVerification.verified_users[0] +\ + ' and ' + self.inputVerification.verified_users[1] + '.' + + elif self.state == 'inviting': + message = self.inputVerification.verified_users[0] + '\'s' +\ + ' invitation to play ' + self.inputVerification.verified_users[1] +\ + ' is still pending. Wait for the game to finish to play a game.' + + elif self.state == 'waiting': + message = '**The bot is not running a game right now!**\n' + \ + 'Type ```start game with user@example.com``` ' +\ + 'to start a game with another user,\n' +\ + 'or type ```start game with computer``` ' +\ + 'to start a game with the computer' + + return prefix + message + + def help_message(self): + return '**Connect Four Bot Help:**\n' + \ + '*Preface all commands with @bot-name*\n\n' + \ + '* To see the current status of the game, type\n' + \ + '```status```\n' + \ + '* To start a game against the computer, type\n' + \ + '```start game with computer```\n' +\ + '* To start a game against another player, type\n' + \ + '```start game with user@example.com```\n' + \ + '* To make your move during a game, type\n' + \ + '```move ```\n' + \ + '* To quit a game at any time, type\n' + \ + '```quit```\n' + \ + '* To withdraw an invitation, type\n' + \ + '```cancel game```' + + def send_message(self, user, content): + self.bot_handler.send_message(dict( + type = 'private', + to = user, + content = content + )) + + # Sends messages returned from helper classes, where user, is the user who sent the bot the original messages + def send_message_arrays(self, user): + if self.opponent_messages: + opponent_array = deepcopy(self.player_cache) + opponent_array.remove(user) + opponent = opponent_array[0] + + for message in self.user_messages: + self.send_message(user, message) + + for message in self.opponent_messages: + self.send_message(opponent, message) + + self.user_messages = [] + self.opponent_messages = [] + + def parse_message(self, message): + content = message['content'].strip() + sender = message['sender_email'] + return (content, sender) + + def usage(self): + return ''' + Bot that allows users to play another user + or the computer in a game of Connect Four. + + To see the entire list of commands, type + @bot-name help + ''' + + def initialize(self, bot_handler): + self.gameCreator = GameCreator(self) + self.inputVerification.reset_commands() + if not bot_handler.storage.contains('connect_four'): + bot_handler.storage.put('connect_four', self.data) + + def handle_message(self, message, bot_handler): + self.bot_handler = bot_handler + + self.data = self.get_stored_data() + self.update_data() + + self.player_cache = self.inputVerification.verified_users + content, sender = self.parse_message(message) + + if not self.inputVerification.valid_command(content.lower()): + self.send_message(sender, 'Sorry, but I couldn\'t understand your input.\n' + 'Type ```help``` to see a full list of commands.') + return + + # Messages that can be sent regardless of state or user + elif content.lower() == 'help' or content == '': + self.send_message(sender, self.help_message()) + return + + elif content.lower() == 'status': + self.send_message(sender, self.status_message()) + return + + elif self.state == 'waiting': + if not self.inputVerification.verify_command(sender, content.lower(), 'waiting'): + self.send_message(sender, self.inputVerification.permission_lacking_message(content)) + + self.gameCreator.handle_message(content, sender) + + elif not self.inputVerification.verify_user(sender): + self.send_message(sender, 'Sorry, but other users are already using the bot.' + 'Type ```status``` to see the current status of the bot.') + return + + elif self.state == 'inviting': + if not self.inputVerification.verify_command(sender, content.lower(), 'inviting'): + self.send_message(sender, self.inputVerification.permission_lacking_message(content)) + return + + self.invitationHandler.handle_message(content, sender) + + elif self.state == 'playing': + if not self.inputVerification.verify_command(sender, content.lower(), 'playing'): + self.send_message(sender, self.inputVerification.permission_lacking_message(content)) + return + + self.gameHandler.handle_message(content, sender) + + self.send_message_arrays(sender) + self.put_stored_data() + +handler_class = ConnectFourBotHandler diff --git a/zulip_bots/zulip_bots/bots/connect_four/controller.py b/zulip_bots/zulip_bots/bots/connect_four/controller.py new file mode 100644 index 0000000..2498b67 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/connect_four/controller.py @@ -0,0 +1,134 @@ +from copy import deepcopy +from random import randint +from functools import reduce + +class ConnectFourModel(object): + ''' + Object that manages running the Connect + Four logic for the Connect Four Bot + ''' + + blank_board = [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]] + + current_board = blank_board + + def update_board(self, board): + self.current_board = deepcopy(board) + + def validate_move(self, column_number): + if column_number < 0 or column_number > 6: + return False + + row = 0 + column = column_number + + return self.current_board[row][column] == 0 + + def available_moves(self): + available_moves = [] + row = 0 + for column in range(0, 7): + if self.current_board[row][column] == 0: + available_moves.append(column) + + return available_moves + + def make_move(self, column_number, token_number): + finding_move = True + row = 5 + column = column_number + + while finding_move: + if self.current_board[row][column] == 0: + self.current_board[row][column] = token_number + finding_move = False + + row -= 1 + + return deepcopy(self.current_board) + + def determine_game_over(self, first_player, second_player): + def get_horizontal_wins(board): + horizontal_sum = 0 + + for row in range(0, 6): + for column in range(0, 4): + horizontal_sum = board[row][column] + board[row][column + 1] + \ + board[row][column + 2] + board[row][column + 3] + if horizontal_sum == -4: + return -1 + elif horizontal_sum == 4: + return 1 + + return 0 + + def get_vertical_wins(board): + vertical_sum = 0 + + for row in range(0, 3): + for column in range(0, 7): + vertical_sum = board[row][column] + board[row + 1][column] + \ + board[row + 2][column] + board[row + 3][column] + if vertical_sum == -4: + return -1 + elif vertical_sum == 4: + return 1 + + return 0 + + def get_diagonal_wins(board): + major_diagonal_sum = 0 + minor_diagonal_sum = 0 + + # Major Diagonl Sum + for row in range(0, 3): + for column in range(0, 4): + major_diagonal_sum = board[row][column] + board[row + 1][column + 1] + \ + board[row + 2][column + 2] + board[row + 3][column + 3] + if major_diagonal_sum == -4: + return -1 + elif major_diagonal_sum == 4: + return 1 + + # Minor Diagonal Sum + for row in range(3, 6): + for column in range(0, 4): + minor_diagonal_sum = board[row][column] + board[row - 1][column + 1] + \ + board[row - 2][column + 2] + board[row - 3][column + 3] + if minor_diagonal_sum == -4: + return -1 + elif minor_diagonal_sum == 4: + return 1 + + return 0 + + # If all tokens in top row are filled (its a draw), product != 0 + top_row_multiple = reduce(lambda x, y: x*y, self.current_board[0]) + + if top_row_multiple != 0: + return 'draw' + + winner = get_horizontal_wins(self.current_board) + \ + get_vertical_wins(self.current_board) + \ + get_diagonal_wins(self.current_board) + + if winner == 1: + return first_player + elif winner == -1: + return second_player + + return False + + def computer_move(self): + # @TODO: Make the computer more intelligent + # perhaps by implementing minimax + available_moves = deepcopy(self.available_moves()) + final_move = available_moves[randint(0, len(available_moves) - 1)] + + return final_move diff --git a/zulip_bots/zulip_bots/bots/connect_four/doc.md b/zulip_bots/zulip_bots/bots/connect_four/doc.md new file mode 100644 index 0000000..69c4448 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/connect_four/doc.md @@ -0,0 +1,77 @@ +# Connect Four Bot + +The Connect Four bot is a Zulip bot that will allow users +to play a game of Connect Four against either another user, +or the computer. All games are run within private messages +sent between the user(s) and the bot. + +Starting a new game with another user requires a simple command, +and the desired opponent's zulip-related email adress: + +``` +@ start game with user@example.com +``` + +Starting a game with the computer is even simpler: + +``` +@ start game with computer +``` + +**See Usage for a complete list of commands** + +*Due to design contraints, the Connect Four Bot +can only run a single game at a time* + +## Setup + +The Connect Four Bot does not require a config file or API key. +It can be used without setup. + +## Usage + +*All commands should be prefaced with* ```@``` + +1. ```help``` : provides the user with relevant +commands for first time users. + +2. ```status``` : due to design contraints, the +bot can only run a single game at a time. This command allows +the user to see the current status of the bot, including +whether or not the bot is running a game, if the bot is waiting +for a player to accept an invitation to play, as well as who +is currently using the bot. + +3. ```start game with user@example.com``` : provided +that the bot is not running a game, this command can be used to +invite another player to play a game of Connect Four with the user. +Note that the user must be specified with their email adress, not +their username. + +4. ```start game with computer``` : provided that the bot is not +running a game, this command will begin a single player game +between the user and a computer player. Note that the currently +implemented computer plays randomly. + +5. ```accept``` : a command that can only be run by an invited +player to accept an invitation to play Connect Four against +another user. + +6. ```decline``` : a command that can only be run by an invited +player to decline an invitation to play Connect Four against +another user. + +7. ```withdraw invitation``` : a command that can only be run by the +inviter to withdraw their invitation to play. Especially +useful if a player does not respond to an invitation for a +long period of time. + +8. ```move ``` : during a game, a player may run +this command on their turn to place a token in the specified +column. + +9. ```quit``` : responds with a confirmation message that asks +the user to confirm they wish to forfeit the game. + +10. ```confirm quit``` : causes the user that runs this command +to forfeit the game. diff --git a/zulip_bots/zulip_bots/bots/connect_four/test_connect_four.py b/zulip_bots/zulip_bots/bots/connect_four/test_connect_four.py new file mode 100644 index 0000000..136c163 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/connect_four/test_connect_four.py @@ -0,0 +1,519 @@ +from zulip_bots.test_lib import BotTestCase + +from contextlib import contextmanager +from mock import MagicMock +from zulip_bots.bots.connect_four.connect_four import * + +class TestConnectFourBot(BotTestCase): + bot_name = 'connect_four' + + def make_request_message(self, content, user='foo@example.com'): + message = dict( + sender_email=user, + content=content, + ) + return message + + # Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled + def verify_response(self, request, expected_response, response_number, data=None, computer_move=None, user = 'foo@example.com'): + ''' + This function serves a similar purpose + to BotTestCase.verify_dialog, but allows + for multiple responses to be validated, + and for mocking of the bot's internal data + ''' + + bot, bot_handler = self._get_handlers() + message = self.make_request_message(request, user) + bot_handler.reset_transcript() + stash = ConnectFourModel.computer_move + + if data: + bot.get_stored_data = MagicMock(return_value = data) + + if computer_move is not None: + ConnectFourModel.computer_move = MagicMock(return_value = computer_move) + + bot.handle_message(message, bot_handler) + + responses = [ + message + for (method, message) + in bot_handler.transcript + ] + + first_response = responses[response_number] + self.assertEqual(expected_response, first_response['content']) + + ConnectFourModel.computer_move = stash + + def help_message(self): + return '**Connect Four Bot Help:**\n' + \ + '*Preface all commands with @bot-name*\n\n' + \ + '* To see the current status of the game, type\n' + \ + '```status```\n' + \ + '* To start a game against the computer, type\n' + \ + '```start game with computer```\n' + \ + '* To start a game against another player, type\n' + \ + '```start game with user@example.com```\n' + \ + '* To make your move during a game, type\n' + \ + '```move ```\n' + \ + '* To quit a game at any time, type\n' + \ + '```quit```\n' + \ + '* To withdraw an invitation, type\n' + \ + '```cancel game```' + + def no_game_status(self): + return '**Connect Four Game Status**\n' + \ + '*If you suspect users are abusing the bot, please alert the bot owner*\n\n' +\ + '**The bot is not running a game right now!**\n' +\ + 'Type ```start game with user@example.com``` to start a game with another user,\n' +\ + 'or type ```start game with computer``` to start a game with the computer' + + def inviting_status(self): + return '**Connect Four Game Status**\n' +\ + '*If you suspect users are abusing the bot, please alert the bot owner*\n\n' +\ + 'foo@example.com\'s invitation to play foo2@example.com' +\ + ' is still pending. Wait for the game to finish to play a game.' + + def one_player_status(self): + return '**Connect Four Game Status**\n' +\ + '*If you suspect users are abusing the bot, please alert the bot owner*\n\n' +\ + 'The bot is currently running a single player game for foo@example.com.' + + def two_player_status(self): + return '**Connect Four Game Status**\n' +\ + '*If you suspect users are abusing the bot, please alert the bot owner*\n\n' +\ + 'The bot is currently running a two player game ' +\ + 'between foo@example.com and foo2@example.com.' + + blank_board = [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]] + + almost_win_board = [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [1, -1, 0, 0, 0, 0, 0], + [1, -1, 0, 0, 0, 0, 0], + [1, -1, 0, 0, 0, 0, 0]] + + almost_draw_board = [ + [1, -1, 1, -1, 1, -1, 0], + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, 0, 1]] + + start_two_player_data = {'state': 'playing', 'game_type': 'two_player', 'board': blank_board, 'users': ['foo@example.com', 'foo2@example.com'], 'turn': 0} + start_one_player_data = {'state': 'playing', 'game_type': 'one_player', 'board': blank_board, 'users': ['foo@example.com'], 'turn': 0} + end_two_player_data = {'state': 'playing', 'game_type': 'two_player', 'board': almost_win_board, 'users': ['foo@example.com', 'foo2@example.com'], 'turn': 0} + end_one_player_data = {'state': 'playing', 'game_type': 'one_player', 'board': almost_win_board, 'users': ['foo@example.com'], 'turn': 0} + inviting_two_player_data = {'state': 'inviting', 'game_type': 'two_player', 'board': blank_board, 'users': ['foo@example.com', 'foo2@example.com'], 'turn': 0} + draw_data = {'state': 'playing', 'game_type': 'one_player', 'board': almost_draw_board, 'users': ['foo@example.com', 'foo2@example.com'], 'turn': 0} + + def test_static_messages(self): + self.verify_response('help', self.help_message(), 0) + self.verify_response('status', self.no_game_status(), 0) + self.verify_response('status', self.inviting_status(), 0, data=self.inviting_two_player_data) + self.verify_response('status', self.one_player_status(), 0, data=self.start_one_player_data) + self.verify_response('status', self.two_player_status(), 0, data=self.start_two_player_data) + + def test_start_game(self): + self.verify_response('start game with computer', '**You started a new game with the computer!**', 0) + self.verify_response('start game with user@example.com', 'You\'ve sent an invitation to play Connect Four with user@example.com. I\'ll let you know when they respond to the invitation', 0) + self.verify_response('start game with foo@example.com', 'You can\'t play against yourself!', 0) + + def test_invitation(self): + self.verify_response('accept', 'You accepted the invitation to play with foo@example.com', 0, data=self.inviting_two_player_data, user = 'foo2@example.com') + self.verify_response('decline', 'You declined the invitation to play with foo@example.com', 0, data=self.inviting_two_player_data, user = 'foo2@example.com') + self.verify_response('withdraw invitation', 'Your invitation to play foo2@example.com has been withdrawn', 0, data=self.inviting_two_player_data) + + def test_move(self): + self.verify_response('move 8', 'That\'s an invalid move. Please specify a column' + + ' with at least one blank space, between 1 and 7', 0, data=self.start_two_player_data) + self.verify_response('move 1', 'You placed your token in column 1.', 0, data=self.start_two_player_data) + self.verify_response('move 1', '**the Computer moved in column 1**.', 3, data=self.start_one_player_data, computer_move=0) + + def test_game_over(self): + self.verify_response('move 1', '**Congratulations, you win! :tada:**', 2, data=self.end_two_player_data) + self.verify_response('move 3', 'Sorry, but the Computer won :cry:', 5, data=self.end_one_player_data, computer_move=1) + self.verify_response('move 7', '**It\'s a draw!**', 2, data = self.draw_data) + + def test_quit(self): + self.verify_response('quit', 'Are you sure you want to quit? You will forfeit the game!\n' + 'Type ```confirm quit``` to forfeit.', 0, data=self.start_two_player_data) + + def test_confirm_quit(self): + self.verify_response('confirm quit', '**You have forfeit the game**\nSorry, but you lost :cry:', 0, data=self.start_two_player_data) + + def test_privilege_check(self): + self.verify_response('move 4', 'Sorry, but you can\'t run the command ```move 4```', 0, data=self.inviting_two_player_data) + self.verify_response('start game with computer', 'Sorry, but other users are already using the bot.' + 'Type ```status``` to see the current status of the bot.', 0, data=self.inviting_two_player_data, user = 'foo3@example.com') + self.verify_response('quit', 'Sorry, but you can\'t run the command ```quit```', 0) + self.verify_response('accept', 'Sorry, but you can\'t run the command ```accept```', 0, data=self.end_two_player_data) + + def test_connect_four_logic(self): + def confirmAvailableMoves(good_moves, bad_moves, board): + connectFourModel.update_board(board) + + for move in good_moves: + self.assertTrue(connectFourModel.validate_move(move)) + + for move in bad_moves: + self.assertFalse(connectFourModel.validate_move(move)) + + def confirmMove(column_number, token_number, initial_board, final_board): + connectFourModel.update_board(initial_board) + test_board = connectFourModel.make_move(column_number, token_number) + + self.assertEqual(test_board, final_board) + + def confirmGameOver(board, result): + connectFourModel.update_board(board) + game_over = connectFourModel.determine_game_over('first_player', 'second_player') + + self.assertEqual(game_over, result) + + def confirmWinStates(array): + for board in array[0]: + confirmGameOver(board, 'first_player') + + for board in array[1]: + confirmGameOver(board, 'second_player') + + connectFourModel = ConnectFourModel() + + # Basic Board setups + blank_board = [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]] + + full_board = [ + [1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1]] + + single_column_board = [ + [1, 1, 1, 0, 1, 1, 1], + [1, 1, 1, 0, 1, 1, 1], + [1, 1, 1, 0, 1, 1, 1], + [1, 1, 1, 0, 1, 1, 1], + [1, 1, 1, 0, 1, 1, 1], + [1, 1, 1, 0, 1, 1, 1]] + + diagonal_board = [ + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1]] + + # Winning Board Setups + # Each array if consists of two arrays: + # The first stores win states for '1' + # The second stores win state for '-1' + # Note these are not necessarily valid board states + # for simplicity (random -1 and 1s could be added) + horizontal_win_boards = [ + [ + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [1, 1, 1, 1, 0, 0, 0]], + + [[0, 0, 0, 1, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 1, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]] + ], + [ + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [-1, -1, -1, -1, 0, 0, 0]], + + [[0, 0, 0, -1, -1, -1, -1], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, -1, -1, -1, -1, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]] + ] + ] + + vertical_win_boards = [ + [ + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]] + ], + [ + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]] + ] + ] + + major_diagonal_win_boards = [ + [ + [[1, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 1]], + + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 0]] + ], + [ + [[-1, 0, 0, 0, 0, 0, 0], + [0, -1, 0, 0, 0, 0, 0], + [0, 0, -1, 0, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, 0, -1, 0, 0], + [0, 0, 0, 0, 0, -1, 0], + [0, 0, 0, 0, 0, 0, -1]], + + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, -1, 0, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, 0, -1, 0, 0], + [0, 0, 0, 0, 0, -1, 0], + [0, 0, 0, 0, 0, 0, 0]] + ] + ] + + minor_diagonal_win_boards = [ + [ + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]] + ], + [ + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, -1, 0, 0, 0, 0], + [0, -1, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, -1], + [0, 0, 0, 0, 0, -1, 0], + [0, 0, 0, 0, -1, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]], + + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, -1, 0, 0], + [0, 0, 0, -1, 0, 0, 0], + [0, 0, -1, 0, 0, 0, 0], + [0, -1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]] + ] + ] + + # Test Move Validation Logic + confirmAvailableMoves([0, 1, 2, 3, 4, 5, 6], [-1, 7], blank_board) + confirmAvailableMoves([3], [0, 1, 2, 4, 5, 6], single_column_board) + confirmAvailableMoves([0, 1, 2, 3, 4, 5], [6], diagonal_board) + + # Test Available Move Logic + connectFourModel.update_board(blank_board) + self.assertEqual(connectFourModel.available_moves(), [0, 1, 2, 3, 4, 5, 6]) + + connectFourModel.update_board(single_column_board) + self.assertEqual(connectFourModel.available_moves(), [3]) + + connectFourModel.update_board(full_board) + self.assertEqual(connectFourModel.available_moves(), []) + + # Test Move Logic + confirmMove(0, 1, blank_board, + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0]]) + + confirmMove(0, -1, blank_board, + [[0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [-1, 0, 0, 0, 0, 0, 0]]) + + confirmMove(0, 1, diagonal_board, + [[0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1]]) + + confirmMove(1, 1, diagonal_board, + [[0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1]]) + + confirmMove(2, 1, diagonal_board, + [[0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1]]) + + confirmMove(3, 1, diagonal_board, + [[0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1]]) + + confirmMove(4, 1, diagonal_board, + [[0, 0, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1]]) + + confirmMove(5, 1, diagonal_board, + [[0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1]]) + + # Test Game Over Logic: + confirmGameOver(blank_board, False) + confirmGameOver(full_board, 'draw') + + # Test Win States: + confirmWinStates(horizontal_win_boards) + confirmWinStates(vertical_win_boards) + confirmWinStates(major_diagonal_win_boards) + confirmWinStates(minor_diagonal_win_boards) + + # Test Computer Move: + connectFourModel.update_board(blank_board) + self.assertTrue(connectFourModel.computer_move() in [0, 1, 2, 3, 4, 5, 6]) + + connectFourModel.update_board(single_column_board) + self.assertEqual(connectFourModel.computer_move(), 3) + + connectFourModel.update_board(diagonal_board) + self.assertTrue(connectFourModel.computer_move() in [0, 1, 2, 3, 4, 5])