diff --git a/zulip_bots/zulip_bots/bots/gameoffifteen/__init__.py b/zulip_bots/zulip_bots/bots/gameoffifteen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zulip_bots/zulip_bots/bots/gameoffifteen/gameoffifteen.py b/zulip_bots/zulip_bots/bots/gameoffifteen/gameoffifteen.py new file mode 100644 index 0000000..73019b8 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/gameoffifteen/gameoffifteen.py @@ -0,0 +1,149 @@ +import copy +import random + +from typing import List, Any, Tuple, Dict +from zulip_bots.game_handler import GameAdapter, BadMoveException + +class GameOfFifteenModel(object): + + final_board = [[0, 1, 2], + [3, 4, 5], + [6, 7, 8]] + + initial_board = [[8, 7, 6], + [5, 4, 3], + [2, 1, 0]] + + def __init__(self, board: Any=None) -> None: + if board is not None: + self.current_board = board + else: + self.current_board = copy.deepcopy(self.initial_board) + + def get_coordinates(self, board: List[List[int]]) -> Dict[int, Tuple[int, int]]: + return { + board[0][0]: (0, 0), + board[0][1]: (0, 1), + board[0][2]: (0, 2), + board[1][0]: (1, 0), + board[1][1]: (1, 1), + board[1][2]: (1, 2), + board[2][0]: (2, 0), + board[2][1]: (2, 1), + board[2][2]: (2, 2), + } + + def determine_game_over(self, players: List[str]) -> str: + if self.won(self.current_board): + return 'current turn' + return '' + + def won(self, board: Any) -> bool: + for i in range(3): + for j in range(3): + if (board[i][j] != self.final_board[i][j]): + return False + return True + + def validate_move(self, tile: int) -> bool: + if tile < 1 or tile > 8: + return False + return True + + def update_board(self, board): + self.current_board = copy.deepcopy(board) + + def make_move(self, move: str, player_number: int, computer_move: bool=False) -> Any: + board = self.current_board + move = move.strip() + move = move.split(' ') + + if '' in move: + raise BadMoveException('You should enter space separated digits.') + moves = len(move) + for m in range(1, moves): + tile = int(move[m]) + coordinates = self.get_coordinates(board) + if tile not in coordinates: + raise BadMoveException('You can only move tiles which exist in the board.') + i, j = coordinates[tile] + if (j-1) > -1 and board[i][j-1] == 0: + board[i][j-1] = tile + board[i][j] = 0 + elif (i-1) > -1 and board[i-1][j] == 0: + board[i-1][j] = tile + board[i][j] = 0 + elif (j+1) < 3 and board[i][j+1] == 0: + board[i][j+1] = tile + board[i][j] = 0 + elif (i+1) < 3 and board[i+1][j] == 0: + board[i+1][j] = tile + board[i][j] = 0 + else: + raise BadMoveException('You can only move tiles which are adjacent to :grey_question:.') + if m == moves - 1: + return board + +class GameOfFifteenMessageHandler(object): + + tiles = { + '0': ':grey_question:', + '1': ':one:', + '2': ':two:', + '3': ':three:', + '4': ':four:', + '5': ':five:', + '6': ':six:', + '7': ':seven:', + '8': ':eight:', + } + + def parse_board(self, board: Any) -> str: + # Header for the top of the board + board_str = '' + + for row in range(3): + board_str += '\n\n' + for column in range(3): + board_str += self.tiles[str(board[row][column])] + return board_str + + def alert_move_message(self, original_player: str, move_info: str) -> str: + tile = move_info.replace('move ', '') + return original_player + ' moved ' + tile + + def game_start_message(self) -> str: + return ("Welcome to Game of Fifteen!" + "To make a move, type @-mention `move ...`") + +class GameOfFifteenBotHandler(GameAdapter): + ''' + Bot that uses the Game Adapter class + to allow users to play Game of Fifteen + ''' + + def __init__(self) -> None: + game_name = 'Game of Fifteen' + bot_name = 'Game of Fifteen' + move_help_message = '* To make your move during a game, type\n' \ + '```move ...```' + move_regex = 'move [\d{1}\s]+$' + model = GameOfFifteenModel + gameMessageHandler = GameOfFifteenMessageHandler + rules = '''Arrange the board’s tiles from smallest to largest, left to right, + top to bottom, and tiles adjacent to :grey_question: can only be moved. + Final configuration will have :grey_question: in top left.''' + + super(GameOfFifteenBotHandler, self).__init__( + game_name, + bot_name, + move_help_message, + move_regex, + model, + gameMessageHandler, + rules, + min_players=1, + max_players=1, + ) + +handler_class = GameOfFifteenBotHandler diff --git a/zulip_bots/zulip_bots/bots/gameoffifteen/requirements.txt b/zulip_bots/zulip_bots/bots/gameoffifteen/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/zulip_bots/zulip_bots/bots/gameoffifteen/test_game_of_fifteen.py b/zulip_bots/zulip_bots/bots/gameoffifteen/test_game_of_fifteen.py new file mode 100644 index 0000000..8c446e4 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/gameoffifteen/test_game_of_fifteen.py @@ -0,0 +1,202 @@ +from zulip_bots.test_lib import BotTestCase + +from contextlib import contextmanager +from unittest.mock import MagicMock +from zulip_bots.bots.gameoffifteen.gameoffifteen import * +from zulip_bots.game_handler import BadMoveException +from typing import Dict, Any, List + + +class TestGameOfFifteenBot(BotTestCase): + bot_name = 'gameoffifteen' + + def make_request_message( + self, + content: str, + user: str='foo@example.com', + user_name: str='foo' + ) -> Dict[str, str]: + message = dict( + sender_email=user, + content=content, + sender_full_name=user_name + ) + return message + + # Function that serves similar purpose to BotTestCase.verify_dialog, but allows for multiple responses to be handled + def verify_response(self, request: str, expected_response: str, response_number: int, user: str='foo@example.com') -> None: + ''' + 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() + + 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']) + + def help_message(self) -> str: + return '''** Game of Fifteen Bot Help:** +*Preface all commands with @**test-bot*** +* To start a game in a stream, type +`start game` +* To quit a game at any time, type +`quit` +* To see rules of this game, type +`rules` +* To make your move during a game, type +```move ...```''' + + def test_static_responses(self) -> None: + self.verify_response('help', self.help_message(), 0) + + def test_game_message_handler_responses(self) -> None: + board = '\n\n:grey_question::one::two:\n\n:three::four::five:\n\n:six::seven::eight:' + bot, bot_handler = self._get_handlers() + self.assertEqual(bot.gameMessageHandler.parse_board( + self.winning_board), board) + self.assertEqual(bot.gameMessageHandler.alert_move_message( + 'foo', 'move 1'), 'foo moved 1') + self.assertEqual(bot.gameMessageHandler.game_start_message( + ), "Welcome to Game of Fifteen!" + "To make a move, type @-mention `move ...`") + + winning_board = [[0, 1, 2], + [3, 4, 5], + [6, 7, 8]] + + def test_game_of_fifteen_logic(self) -> None: + def confirmAvailableMoves( + good_moves: List[int], + bad_moves: List[int], + board: List[List[int]] + ) -> None: + gameOfFifteenModel.update_board(board) + for move in good_moves: + self.assertTrue(gameOfFifteenModel.validate_move(move)) + + for move in bad_moves: + self.assertFalse(gameOfFifteenModel.validate_move(move)) + + def confirmMove( + tile: str, + token_number: int, + initial_board: List[List[int]], + final_board: List[List[int]] + ) -> None: + gameOfFifteenModel.update_board(initial_board) + test_board = gameOfFifteenModel.make_move( + 'move ' + tile, token_number) + + self.assertEqual(test_board, final_board) + + def confirmGameOver(board: List[List[int]], result: str) -> None: + gameOfFifteenModel.update_board(board) + game_over = gameOfFifteenModel.determine_game_over( + ['first_player']) + + self.assertEqual(game_over, result) + + def confirm_coordinates(board: List[List[int]], result: Dict[int, Tuple[int, int]]) -> None: + gameOfFifteenModel.update_board(board) + coordinates = gameOfFifteenModel.get_coordinates(board) + self.assertEqual(coordinates, result) + + gameOfFifteenModel = GameOfFifteenModel() + + # Basic Board setups + initial_board = [[8, 7, 6], + [5, 4, 3], + [2, 1, 0]] + + sample_board = [[7, 6, 8], + [3, 0, 1], + [2, 4, 5]] + + winning_board = [[0, 1, 2], + [3, 4, 5], + [6, 7, 8]] + + # Test Move Validation Logic + confirmAvailableMoves([1, 2, 3, 4, 5, 6, 7, 8], [0, 9, -1], initial_board) + + # Test Move Logic + confirmMove('1', 0, initial_board, + [[8, 7, 6], + [5, 4, 3], + [2, 0, 1]]) + + confirmMove('1 2', 0, initial_board, + [[8, 7, 6], + [5, 4, 3], + [0, 2, 1]]) + + confirmMove('1 2 5', 0, initial_board, + [[8, 7, 6], + [0, 4, 3], + [5, 2, 1]]) + + confirmMove('1 2 5 4', 0, initial_board, + [[8, 7, 6], + [4, 0, 3], + [5, 2, 1]]) + + confirmMove('3', 0, sample_board, + [[7, 6, 8], + [0, 3, 1], + [2, 4, 5]]) + + confirmMove('3 7', 0, sample_board, + [[0, 6, 8], + [7, 3, 1], + [2, 4, 5]]) + + # Test coordinates logic: + confirm_coordinates(initial_board, {8: (0, 0), + 7: (0, 1), + 6: (0, 2), + 5: (1, 0), + 4: (1, 1), + 3: (1, 2), + 2: (2, 0), + 1: (2, 1), + 0: (2, 2)}) + + # Test Game Over Logic: + confirmGameOver(winning_board, 'current turn') + confirmGameOver(sample_board, '') + + def test_invalid_moves(self) -> None: + model = GameOfFifteenModel() + move1 = 'move 2' + move2 = 'move 5' + move3 = 'move 23' + move4 = 'move 0' + move5 = 'move 1 2' + initial_board = [[8, 7, 6], + [5, 4, 3], + [2, 1, 0]] + + model.update_board(initial_board) + with self.assertRaises(BadMoveException): + model.make_move(move1, player_number=0) + with self.assertRaises(BadMoveException): + model.make_move(move2, player_number=0) + with self.assertRaises(BadMoveException): + model.make_move(move3, player_number=0) + with self.assertRaises(BadMoveException): + model.make_move(move4, player_number=0) + with self.assertRaises(BadMoveException): + model.make_move(move5, player_number=0)