zulip_bots: Migrate tictactoe bot to new game_handler.

This commit is contained in:
fredfishgames 2018-01-19 15:55:51 +00:00 committed by showell
parent 3a438cafa9
commit 3cbb16722d
2 changed files with 185 additions and 271 deletions

View file

@ -1,107 +1,69 @@
from zulip_bots.test_lib import BotTestCase
from zulip_bots.game_handler import GameInstance
from unittest.mock import patch
from typing import List, Tuple, Any
class TestTictactoeBot(BotTestCase):
class TestTicTacToeBot(BotTestCase):
bot_name = 'tictactoe'
def test_bot(self):
messages = [ # Template for message inputs to test, absent of message content
{
'type': 'stream',
'display_recipient': 'some stream',
'subject': 'some subject',
'sender_email': 'foo_sender@zulip.com',
},
{
'type': 'private',
'sender_email': 'foo_sender@zulip.com',
},
]
private_response = {
'type': 'private',
'to': 'foo_sender@zulip.com',
'subject': 'foo_sender@zulip.com', # FIXME Requiring this in bot is a bug?
# FIXME: Add tests for computer moves
# FIXME: Add test lib for game_handler
def test_static_responses(self) -> None:
model, message_handler = self._get_game_handlers()
self.assertNotEqual(message_handler.get_player_color(0), None)
self.assertNotEqual(message_handler.game_start_message(), None)
self.assertEqual(message_handler.alert_move_message(
'foo', 'move 3'), 'foo put a token at 3')
def test_has_attributes(self) -> None:
model, message_handler = self._get_game_handlers()
self.assertTrue(hasattr(message_handler, 'parse_board') is not None)
self.assertTrue(
hasattr(message_handler, 'alert_move_message') is not None)
self.assertTrue(hasattr(model, 'current_board') is not None)
self.assertTrue(hasattr(model, 'determine_game_over') is not None)
def test_parse_board(self) -> None:
board = [[0, 1, 0],
[0, 0, 0],
[0, 0, 2]]
response = ':one: :cross_mark_button: :three:\n\n' +\
':four: :five: :six:\n\n' +\
':seven: :eight: :o_button:\n\n'
self._test_parse_board(board, response)
def _test_parse_board(self, board: List[List[int]], expected_response: str) -> None:
model, message_handler = self._get_game_handlers()
response = message_handler.parse_board(board)
self.assertEqual(response, expected_response)
def _test_determine_game_over(self, board: List[List[int]], players: List[str], expected_response: str) -> None:
model, message_handler = self._get_game_handlers()
response = model.determine_game_over(players)
self.assertEqual(response, expected_response)
def add_user_to_cache(self, name: str, bot: Any=None) -> Any:
if bot is None:
bot, bot_handler = self._get_handlers()
message = {
'sender_email': '{}@example.com'.format(name),
'sender_full_name': '{}'.format(name)
}
bot.add_user_to_cache(message)
return bot
msg = dict(
help = "*Help for Tic-Tac-Toe bot* \nThe bot responds to messages starting with @mention-bot.\n**@mention-bot new** will start a new game (but not if you're already in the middle of a game). You must type this first to start playing!\n**@mention-bot help** will return this help function.\n**@mention-bot quit** will quit from the current game.\n**@mention-bot <coordinate>** will make a move at the given coordinate.\nCoordinates are entered in a (row, column) format. Numbering is from top to bottom and left to right. \nHere are the coordinates of each position. (Parentheses and spaces are optional). \n(1, 1) (1, 2) (1, 3) \n(2, 1) (2, 2) (2, 3) \n(3, 1) (3, 2) (3, 3) \n",
didnt_understand = "Hmm, I didn't understand your input. Type **@tictactoe help** or **@ttt help** to see valid inputs.",
new_game = "Welcome to tic-tac-toe! You'll be x's and I'll be o's. Your move first!\nCoordinates are entered in a (row, column) format. Numbering is from top to bottom and left to right.\nHere are the coordinates of each position. (Parentheses and spaces are optional.) \n(1, 1) (1, 2) (1, 3) \n(2, 1) (2, 2) (2, 3) \n(3, 1) (3, 2) (3, 3) \n Your move would be one of these. To make a move, type @mention-bot followed by a space and the coordinate.",
already_playing = "You're already playing a game! Type **@tictactoe help** or **@ttt help** to see valid inputs.",
already_played_there = 'That space is already filled, sorry!',
successful_quit = "You've successfully quit the game.",
after_1_1 = ("[ x _ _ ]\n[ _ _ _ ]\n[ _ _ _ ]\n"
"My turn:\n[ x _ _ ]\n[ _ o _ ]\n[ _ _ _ ]\n"
"Your turn! Enter a coordinate or type help."),
after_2_1 = ("[ x _ _ ]\n[ x o _ ]\n[ _ _ _ ]\n"
"My turn:\n[ x _ _ ]\n[ x o _ ]\n[ o _ _ ]\n"
"Your turn! Enter a coordinate or type help."),
after_1_3 = ("[ x _ x ]\n[ x o _ ]\n[ o _ _ ]\n"
"My turn:\n[ x o x ]\n[ x o _ ]\n[ o _ _ ]\n"
"Your turn! Enter a coordinate or type help."),
after_3_2 = ("[ x o x ]\n[ x o _ ]\n[ o x _ ]\n"
"My turn:\n[ x o x ]\n[ x o _ ]\n[ o x o ]\n"
"Your turn! Enter a coordinate or type help."),
after_2_3_draw = ("[ x o x ]\n[ x o x ]\n[ o x o ]\n"
"It's a draw! Neither of us was able to win."),
after_2_3_try_lose = ("[ x _ _ ]\n[ _ o x ]\n[ _ _ _ ]\n"
"My turn:\n[ x _ _ ]\n[ _ o x ]\n[ _ o _ ]\n"
"Your turn! Enter a coordinate or type help."),
after_2_1_lost = ("[ x _ _ ]\n[ x o x ]\n[ _ o _ ]\n"
"My turn:\n[ x o _ ]\n[ x o x ]\n[ _ o _ ]\n"
"Game over! I've won!"),
)
def setup_game(self) -> None:
bot = self.add_user_to_cache('foo')
self.add_user_to_cache('baz', bot)
instance = GameInstance(bot, False, 'test game', 'abc123', [
'foo@example.com', 'baz@example.com'], 'test')
bot.instances.update({'abc123': instance})
instance.start()
return bot
conversation = [
# Empty message
("", msg['didnt_understand']),
# Non-command
("adboh", msg['didnt_understand']),
# Help command
("help", msg['help']),
# Command: quit not understood with no game
("quit", msg['didnt_understand']),
# Can quit if new game and have state
("new", msg['new_game']),
("quit", msg['successful_quit']),
# Quit not understood when no game FIXME improve response?
("quit", msg['didnt_understand']),
# New right after new just restarts
("new", msg['new_game']),
("new", msg['new_game']),
# Make a corner play
("(1,1)", msg['after_1_1']),
# New while playing doesn't just restart
("new", msg['already_playing']),
# User played in this location already
("(1,1)", msg['already_played_there']),
# ... and bot played here
("(2,2)", msg['already_played_there']),
("quit", msg['successful_quit']),
# Can't play without game FIXME improve response?
("(1,1)", msg['didnt_understand']),
("new", msg['new_game']),
# Value out of range FIXME improve response?
("(1,5)", msg['didnt_understand']),
# Value out of range FIXME improve response?
("0,1", msg['didnt_understand']),
# Sequence of moves to show valid input formats:
("1,1", msg['after_1_1']),
("2, 1", msg['after_2_1']),
("(1,3)", msg['after_1_3']),
("3,2", msg['after_3_2']),
("2,3", msg['after_2_3_draw']),
# Game already over; can't quit FIXME improve response?
("quit", msg['didnt_understand']),
("new", msg['new_game']),
("1,1", msg['after_1_1']),
("2,3", msg['after_2_3_try_lose']),
("2,1", msg['after_2_1_lost']),
# Game already over; can't quit FIXME improve response?
("quit", msg['didnt_understand']),
]
with patch('zulip_bots.bots.tictactoe.tictactoe.random.choice') as choice:
choice.return_value = [2, 2]
self.verify_dialog(conversation)
def _get_game_handlers(self) -> Tuple[Any, Any]:
bot, bot_handler = self._get_handlers()
return bot.model, bot.gameMessageHandler

View file

@ -1,13 +1,15 @@
import copy
import random
from typing import List
from typing import List, Any, Tuple
from zulip_bots.game_handler import GameAdapter, BadMoveException
# -------------------------------------
State = List[List[str]]
class TicTacToeGame(object):
class TicTacToeModel(object):
smarter = True
# If smarter is True, the computer will do some extra thinking - it'll be harder for the user.
@ -21,52 +23,45 @@ class TicTacToeGame(object):
[(0, 2), (1, 1), (2, 0)] # Diagonal 2
]
initial_board = [["_", "_", "_"],
["_", "_", "_"],
["_", "_", "_"]]
initial_board = [[0, 0, 0],
[0, 0, 0],
[0, 0, 0]]
def __init__(self, board=None):
def __init__(self, board: Any=None) -> None:
if board is not None:
self.board = board
self.current_board = board
else:
self.board = copy.deepcopy(self.initial_board)
self.current_board = copy.deepcopy(self.initial_board)
def get_state(self) -> State:
return self.board
def is_new_game(self) -> bool:
return self.board == self.initial_board
def display_row(self, row):
''' Takes the row passed in as a list and returns it as a string. '''
row_string = " ".join([e.strip() for e in row])
return("[ {} ]\n".format(row_string))
def display_board(self, board):
''' Takes the board as a nested list and returns a nice version for the user. '''
return "".join([self.display_row(r) for r in board])
def get_value(self, board, position):
def get_value(self, board: Any, position: Tuple[int, int]) -> int:
return board[position[0]][position[1]]
def board_is_full(self, board):
def determine_game_over(self, players: List[str]) -> str:
if self.contains_winning_move(self.current_board):
return 'current turn'
if self.board_is_full(self.current_board):
return 'draw'
return ''
def board_is_full(self, board: Any) -> bool:
''' Determines if the board is full or not. '''
for row in board:
for element in row:
if element == "_":
if element == 0:
return False
return True
def contains_winning_move(self, board): # Used for current board & trial computer board
# Used for current board & trial computer board
def contains_winning_move(self, board: Any) -> bool:
''' Returns true if all coordinates in a triplet have the same value in them (x or o) and no coordinates
in the triplet are blank. '''
for triplet in self.triplets:
if (self.get_value(board, triplet[0]) == self.get_value(board, triplet[1]) ==
self.get_value(board, triplet[2]) != "_"):
self.get_value(board, triplet[2]) != 0):
return True
return False
def get_locations_of_char(self, board, char):
def get_locations_of_char(self, board: Any, char: int) -> List[List[int]]:
''' Gets the locations of the board that have char in them. '''
locations = []
for row in range(3):
@ -75,83 +70,91 @@ class TicTacToeGame(object):
locations.append([row, col])
return locations
def two_blanks(self, triplet, board):
''' Determines which rows/columns/diagonals have two blank spaces and an 'o' already in them. It's more advantageous
def two_blanks(self, triplet: List[Tuple[int, int]], board: Any) -> List[Tuple[int, int]]:
''' Determines which rows/columns/diagonals have two blank spaces and an 2 already in them. It's more advantageous
for the computer to move there. This is used when the computer makes its move. '''
o_found = False
for position in triplet:
if self.get_value(board, position) == "o":
if self.get_value(board, position) == 2:
o_found = True
break
blanks_list = []
if o_found:
for position in triplet:
if self.get_value(board, position) == "_":
if self.get_value(board, position) == 0:
blanks_list.append(position)
if len(blanks_list) == 2:
return blanks_list
return []
def computer_move(self, board):
def computer_move(self, board: Any, player_number: Any) -> Any:
''' The computer's logic for making its move. '''
my_board = copy.deepcopy(board) # First the board is copied; used later on
blank_locations = self.get_locations_of_char(my_board, "_")
x_locations = self.get_locations_of_char(board, "x") # Gets the locations that already have x's
corner_locations = [[0, 0], [0, 2], [2, 0], [2, 2]] # List of the coordinates of the corners of the board
edge_locations = [[1, 0], [0, 1], [1, 2], [2, 1]] # List of the coordinates of the edge spaces of the board
my_board = copy.deepcopy(
board) # First the board is copied; used later on
blank_locations = self.get_locations_of_char(my_board, 0)
# Gets the locations that already have x's
x_locations = self.get_locations_of_char(board, 1)
# List of the coordinates of the corners of the board
corner_locations = [[0, 0], [0, 2], [2, 0], [2, 2]]
# List of the coordinates of the edge spaces of the board
edge_locations = [[1, 0], [0, 1], [1, 2], [2, 1]]
if blank_locations == []: # If no empty spaces are left, the computer can't move anyway, so it just returns the board.
# If no empty spaces are left, the computer can't move anyway, so it just returns the board.
if blank_locations == []:
return board
if len(x_locations) == 1: # This is special logic only used on the first move.
# If the user played first in the corner or edge, the computer should move in the center.
# This is special logic only used on the first move.
if len(x_locations) == 1:
# If the user played first in the corner or edge,
# the computer should move in the center.
if x_locations[0] in corner_locations or x_locations[0] in edge_locations:
board[1][1] = "o"
board[1][1] = 2
# If user played first in the center, the computer should move in the corner. It doesn't matter which corner.
else:
location = random.choice(corner_locations)
row = location[0]
col = location[1]
board[row][col] = "o"
board[row][col] = 2
return board
# This logic is used on all other moves.
# First I'll check if the computer can win in the next move. If so, that's where the computer will play.
# The check is done by replacing the blank locations with o's and seeing if the computer would win in each case.
for row, col in blank_locations:
my_board[row][col] = "o"
my_board[row][col] = 2
if self.contains_winning_move(my_board):
board[row][col] = "o"
board[row][col] = 2
return board
else:
my_board[row][col] = "_" # Revert if not winning
my_board[row][col] = 0 # Revert if not winning
# If the computer can't immediately win, it wants to make sure the user can't win in their next move, so it
# checks to see if the user needs to be blocked.
# The check is done by replacing the blank locations with x's and seeing if the user would win in each case.
for row, col in blank_locations:
my_board[row][col] = "x"
my_board[row][col] = 1
if self.contains_winning_move(my_board):
board[row][col] = "o"
board[row][col] = 2
return board
else:
my_board[row][col] = "_" # Revert if not winning
my_board[row][col] = 0 # Revert if not winning
# Assuming nobody will win in their next move, now I'll find the best place for the computer to win.
for row, col in blank_locations:
if ('x' not in my_board[row] and my_board[0][col] != 'x' and my_board[1][col] !=
'x' and my_board[2][col] != 'x'):
board[row][col] = 'o'
if (1 not in my_board[row] and my_board[0][col] != 1 and my_board[1][col] !=
1 and my_board[2][col] != 1):
board[row][col] = 2
return board
# If no move has been made, choose a random blank location. If smarter is True, the computer will choose a
# random blank location from a set of better locations to play. These locations are determined by seeing if
# there are two blanks and an 'o' in each row, column, and diagonal (done in two_blanks).
# there are two blanks and an 2 in each row, column, and diagonal (done in two_blanks).
# If smarter is False, all blank locations can be chosen.
if self.smarter:
blanks = []
blanks = [] # type: Any
for triplet in self.triplets:
result = self.two_blanks(triplet, board)
if result:
@ -164,17 +167,17 @@ class TicTacToeGame(object):
location = random.choice(blank_list)
row = location[0]
col = location[1]
board[row][col] = 'o'
board[row][col] = 2
return board
else:
location = random.choice(blank_locations)
row = location[0]
col = location[1]
board[row][col] = 'o'
board[row][col] = 2
return board
def is_valid_move(self, move):
def is_valid_move(self, move: str) -> bool:
''' Checks the validity of the coordinate input passed in to make sure it's not out-of-bounds (ex. 5, 5) '''
try:
split_move = move.split(",")
@ -187,78 +190,58 @@ class TicTacToeGame(object):
valid = False
return valid
def tictactoe(self, move):
board = self.board
printed_boards = dict(after_player = "", after_computer = "")
def make_move(self, move: str, player_number: int, computer_move: bool=False) -> Any:
if computer_move:
return self.computer_move(self.current_board, player_number + 1)
move_coords_str = coords_from_command(move)
if not self.is_valid_move(move_coords_str):
raise BadMoveException('Make sure your move is from 0-9')
board = self.current_board
move_coords = move_coords_str.split(',')
# Subtraction must be done to convert to the right indices,
# since computers start numbering at 0.
row = (int(move_coords[1])) - 1
column = (int(move_coords[0])) - 1
if board[row][column] != 0:
raise BadMoveException('Make sure your space hasn\'t already been filled.')
board[row][column] = player_number + 1
return board
# Subtraction must be done to convert to the right indices, since computers start numbering at 0.
row = (int(move[0])) - 1
column = (int(move[-1])) - 1
if board[row][column] != "_":
return ("filled", printed_boards)
class TicTacToeMessageHandler(object):
tokens = [':cross_mark_button:', ':o_button:']
def parse_row(self, row: Tuple[int, int], row_num: int) -> str:
''' Takes the row passed in as a list and returns it as a string. '''
row_chars = []
num_symbols = [':one:', ':two:', ':three:', ':four:', ':five:', ':six:', ':seven:', ':eight:', ':nine:']
for i, e in enumerate(row):
if e == 0:
row_chars.append(num_symbols[row_num * 3 + i])
else:
board[row][column] = "x"
row_chars.append(self.get_player_color(e - 1))
row_string = ' '.join(row_chars)
return row_string + '\n\n'
printed_boards['after_player'] = self.display_board(board)
def parse_board(self, board: Any) -> str:
''' Takes the board as a nested list and returns a nice version for the user. '''
return "".join([self.parse_row(r, r_num) for r_num, r in enumerate(board)])
# Check to see if the user won/drew after they made their move. If not, it's the computer's turn.
if self.contains_winning_move(board):
return ("player_win", printed_boards)
def get_player_color(self, turn: int) -> str:
return self.tokens[turn]
if self.board_is_full(board):
return ("draw", printed_boards)
def alert_move_message(self, original_player: str, move_info: str) -> str:
move_info = move_info.replace('move ', '')
return '{} put a token at {}'.format(original_player, move_info)
self.computer_move(board)
printed_boards['after_computer'] = self.display_board(board)
def game_start_message(self) -> str:
return ("Welcome to tic-tac-toe!"
"To make a move, type @-mention `move <number>`")
# Checks to see if the computer won after it makes its move. (The computer can't draw, so there's no point
# in checking.) If the computer didn't win, the user gets another turn.
if self.contains_winning_move(board):
return ("computer_win", printed_boards)
return ("next_turn", printed_boards)
# -------------------------------------
long_help_text = ("*Help for Tic-Tac-Toe bot* \n"
"The bot responds to messages starting with @mention-bot.\n"
"**@mention-bot new** will start a new game (but not if you're "
"already in the middle of a game). You must type this first to start playing!\n"
"**@mention-bot help** will return this help function.\n"
"**@mention-bot quit** will quit from the current game.\n"
"**@mention-bot <coordinate>** will make a move at the given coordinate.\n"
"Coordinates are entered in a (row, column) format. Numbering is from "
"top to bottom and left to right. \n"
"Here are the coordinates of each position. (Parentheses and spaces are optional). \n"
"(1, 1) (1, 2) (1, 3) \n(2, 1) (2, 2) (2, 3) \n(3, 1) (3, 2) (3, 3) \n")
short_help_text = "Type **@tictactoe help** or **@ttt help** to see valid inputs."
new_game_text = ("Welcome to tic-tac-toe! You'll be x's and I'll be o's."
" Your move first!\n"
"Coordinates are entered in a (row, column) format. "
"Numbering is from top to bottom and left to right.\n"
"Here are the coordinates of each position. (Parentheses and spaces are optional.) \n"
"(1, 1) (1, 2) (1, 3) \n(2, 1) (2, 2) (2, 3) \n(3, 1) (3, 2) (3, 3) \n "
"Your move would be one of these. To make a move, type @mention-bot "
"followed by a space and the coordinate.")
quit_game_text = "You've successfully quit the game."
unknown_message_text = "Hmm, I didn't understand your input."
already_playing_text = "You're already playing a game!"
mid_move_text = "My turn:"
end_of_move_text = {
"filled": "That space is already filled, sorry!",
"next_turn": "Your turn! Enter a coordinate or type help.",
"computer_win": "Game over! I've won!",
"player_win": "Game over! You've won!",
"draw": "It's a draw! Neither of us was able to win.",
}
# -------------------------------------
class ticTacToeHandler(object):
class ticTacToeHandler(GameAdapter):
'''
You can play tic-tac-toe in a private message with
tic-tac-toe bot! Make sure your message starts with
You can play tic-tac-toe! Make sure your message starts with
"@mention-bot".
'''
META = {
@ -266,69 +249,38 @@ class ticTacToeHandler(object):
'description': 'Lets you play Tic-tac-toe against a computer.',
}
def usage(self):
def usage(self) -> str:
return '''
You can play tic-tac-toe with the computer now! Make sure your
You can play tic-tac-toe now! Make sure your
message starts with @mention-bot.
'''
def handle_message(self, message, bot_handler):
command = message['content']
original_sender = message['sender_email']
def __init__(self) -> None:
game_name = 'Tic Tac Toe'
bot_name = 'tictactoe'
move_help_message = '* To move during a game, type\n`move <number>`'
move_regex = 'move \d$'
model = TicTacToeModel
gameMessageHandler = TicTacToeMessageHandler
super(ticTacToeHandler, self).__init__(
game_name,
bot_name,
move_help_message,
move_regex,
model,
gameMessageHandler,
supports_computer=True
)
storage = bot_handler.storage
if not storage.contains(original_sender):
storage.put(original_sender, None)
state = storage.get(original_sender)
user_game = TicTacToeGame(state) if state else None
move = None
if command == 'new':
if not user_game:
user_game = TicTacToeGame()
move = "new"
if not user_game.is_new_game():
response = " ".join([already_playing_text, short_help_text])
else:
response = new_game_text
elif command == 'help':
response = long_help_text
elif (user_game) and user_game.is_valid_move(coords_from_command(command)):
move, printed_boards = user_game.tictactoe(coords_from_command(command))
mid_text = mid_move_text+"\n" if printed_boards['after_computer'] else ""
response = "".join([printed_boards['after_player'], mid_text,
printed_boards['after_computer'],
end_of_move_text[move]])
elif (user_game) and command == 'quit':
move = "quit"
response = quit_game_text
else:
response = " ".join([unknown_message_text, short_help_text])
if move is not None:
if any(reset_text in move for reset_text in ("win", "draw", "quit")):
storage.put(original_sender, None)
elif any(keep_text == move for keep_text in ("new", "next_turn")):
storage.put(original_sender, user_game.get_state())
else: # "filled" => no change, state remains the same
pass
bot_handler.send_message(dict(
type = 'private',
to = original_sender,
subject = message['sender_email'],
content = response,
))
def coords_from_command(cmd):
def coords_from_command(cmd: str) -> str:
# This function translates the input command into a TicTacToeGame move.
# It should return two indices, each one of (1,2,3), separated by a comma, eg. "3,2"
''' As there are various ways to input a coordinate (with/without parentheses, with/without spaces, etc.) the
input is stripped to just the numbers before being used in the program. '''
cmd = cmd.replace("(", "")
cmd = cmd.replace(")", "")
cmd = cmd.replace(" ", "")
cmd = cmd.strip()
cmd_num = int(cmd.replace('move ', '')) - 1
cmd = '{},{}'.format((cmd_num % 3) + 1, (cmd_num // 3) + 1)
return cmd
handler_class = ticTacToeHandler