Skip to content

[WIP] bots: Create trivia_quiz_game bot #565

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions tools/provision
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ the Python version this command is executed with."""
venv_dir,
venv_exec_dir,
'activate')
# We make the path look like a Unix path, because most Windows users
# are likely to be running in a bash shell.
activate_command = activate_command.replace(os.sep, '/')
print('\nRun the following to enter into the virtualenv:\n')
print(bold + ' source ' + activate_command + end_format + "\n")

Expand Down
2 changes: 2 additions & 0 deletions tools/run-mypy
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ force_include = [
"zulip_bots/zulip_bots/bots/tictactoe/test_tictactoe.py",
"zulip_bots/zulip_bots/bots/trivia_quiz/trivia_quiz.py",
"zulip_bots/zulip_bots/bots/trivia_quiz/test_trivia_quiz.py",
"zulip_bots/zulip_bots/bots/trivia_quiz_game/trivia_quiz_game.py",
"zulip_bots/zulip_bots/bots/trivia_quiz_game/test_trivia_quiz_game.py",
"zulip_bots/zulip_bots/bots/game_handler_bot/game_handler_bot.py",
"zulip_bots/zulip_bots/bots/game_handler_bot/test_game_handler_bot.py",
"zulip_bots/zulip_bots/bots/trello/trello.py",
Expand Down
2 changes: 1 addition & 1 deletion zulip_bots/zulip_bots/bots/connect_four/connect_four.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def parse_board(self, board: Any) -> str:
def get_player_color(self, turn: int) -> str:
return self.tokens[turn]

def alert_move_message(self, original_player: str, move_info: str) -> str:
def alert_move_message(self, original_player: str, move_info: str, move_data: Any = None) -> str:
column_number = move_info.replace('move ', '')
return original_player + ' moved in column ' + column_number

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def parse_board(self, board: Any) -> str:
def get_player_color(self, turn: int) -> str:
return self.tokens[turn]

def alert_move_message(self, original_player: str, move_info: str) -> str:
def alert_move_message(self, original_player: str, move_info: str, move_data: Any = None) -> str:
column_number = move_info.replace('move ', '')
return original_player + ' moved in column ' + column_number

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def parse_board(self, board: Any) -> str:
board_str += self.tiles[str(board[row][column])]
return board_str

def alert_move_message(self, original_player: str, move_info: str) -> str:
def alert_move_message(self, original_player: str, move_info: str, move_data: Any = None) -> str:
tile = move_info.replace('move ', '')
return original_player + ' moved ' + tile

Expand Down
2 changes: 1 addition & 1 deletion zulip_bots/zulip_bots/bots/merels/merels.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def parse_board(self, board: Any) -> str:
def get_player_color(self, turn: int) -> str:
return self.tokens[turn]

def alert_move_message(self, original_player: str, move_info: str) -> str:
def alert_move_message(self, original_player: str, move_info: str, move_data: Any = None) -> str:
return original_player + " :" + move_info

def game_start_message(self) -> str:
Expand Down
2 changes: 1 addition & 1 deletion zulip_bots/zulip_bots/bots/tictactoe/tictactoe.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ def parse_board(self, board: Any) -> str:
def get_player_color(self, turn: int) -> str:
return self.tokens[turn]

def alert_move_message(self, original_player: str, move_info: str) -> str:
def alert_move_message(self, original_player: str, move_info: str, move_data: Any = None) -> str:
move_info = move_info.replace('move ', '')
return '{} put a token at {}'.format(original_player, move_info)

Expand Down
Empty file.
102 changes: 102 additions & 0 deletions zulip_bots/zulip_bots/bots/trivia_quiz_game/controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from zulip_bots.game_handler import BadMoveException
import html
import requests
import random

from typing import Optional, Any, Dict

class NotAvailableException(Exception):
def __init__(self, message: str) -> None:
self.message = message

def __str__(self) -> str:
return self.message

class TriviaQuizGameModel(object):
def __init__(self):
# This could throw an exception. It will be picked up by
# game_handler and the game will end
self.current_board = self.get_trivia_quiz()
self.scores = {} # type: Dict[int, int]

def validate_move(self, answer):
return answer in "ABCD"

def make_move(self, move, player_number, is_computer=False):
if player_number not in self.scores:
self.scores[player_number] = 0
if not self.validate_move(move):
raise BadMoveException("Move not valid")
if move == self.current_board['correct_letter']:
self.scores[player_number] += 1
else:
self.scores[player_number] -= 1
if self.scores[player_number] < 0:
self.scores[player_number] = 0
self.current_board = self.get_trivia_quiz()
return {
'correct': move == self.current_board['correct_letter'],
'correct_letter': self.current_board['correct_letter'],
'score': self.scores[player_number]
}

def determine_game_over(self, players):
for player_number, score in self.scores.items():
if score >= 5:
# Game over
return players[player_number]
return

def get_trivia_quiz(self) -> Dict[str, Any]:
payload = self.get_trivia_payload()
quiz = self.get_quiz_from_payload(payload)
return quiz

def get_trivia_payload(self) -> Dict[str, Any]:

url = 'https://opentdb.com/api.php?amount=1&type=multiple'

try:
data = requests.get(url)

except requests.exceptions.RequestException:
raise NotAvailableException("Uh-Oh! Trivia service is down.")

if data.status_code != 200:
raise NotAvailableException("Uh-Oh! Trivia service is down.")

payload = data.json()
return payload

def get_quiz_from_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
result = payload['results'][0]
question = result['question']
letters = ['A', 'B', 'C', 'D']
random.shuffle(letters)
correct_letter = letters[0]
answers = dict()
answers[correct_letter] = result['correct_answer']
for i in range(3):
answers[letters[i+1]] = result['incorrect_answers'][i]

def fix_quotes(s: str) -> Optional[str]:
# opentdb is nice enough to escape HTML for us, but
# we are sending this to code that does that already :)
#
# Meanwhile Python took until version 3.4 to have a
# simple html.unescape function.
try:
return html.unescape(s)
except Exception:
raise Exception('Please use python3.4 or later for this bot.')
answers = {
letter: fix_quotes(answer)
for letter, answer
in answers.items()
}
quiz = dict(
question=fix_quotes(question),
answers=answers,
correct_letter=correct_letter,
)
return quiz
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"request":{
"api_url":"https://opentdb.com/api.php?amount=1&type=multiple"
},
"response":{
"response_code":0,
"results":[
{
"category":"Animals",
"type":"multiple",
"difficulty":"easy",
"question":"Which class of animals are newts members of?",
"correct_answer":"Amphibian",
"incorrect_answers":["Fish","Reptiles","Mammals"]
}
]
},
"response-headers":{
"status":200,
"ok":true,
"content-type":"application/json; charset=utf-8"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"question": "Which class of animals are newts members of?",
"answers": {
"A": "Amphibian",
"B": "Fish",
"C": "Reptiles",
"D": "Mammals"
},
"correct_letter": "A"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"request": {
"api_url":"https://opentdb.com/api.php?amount=1&type=multiple"
},
"response": {
"data": {
"status_code":404
}
},
"response-headers":{
"status":404,
"content-type":"text/html"
}
}
153 changes: 153 additions & 0 deletions zulip_bots/zulip_bots/bots/trivia_quiz_game/test_trivia_quiz_game.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import json

from unittest.mock import patch

from zulip_bots.test_lib import (
BotTestCase,
DefaultTests,
read_bot_fixture_data,
)

from zulip_bots.request_test_lib import (
mock_request_exception
)

from zulip_bots.bots.trivia_quiz_game.controller import (
TriviaQuizGameModel,
NotAvailableException
)

from zulip_bots.bots.trivia_quiz_game.trivia_quiz_game import (
TriviaQuizGameMessageHandler
)

from zulip_bots.game_handler import BadMoveException

class TestTriviaQuizGameBot(BotTestCase, DefaultTests):
bot_name = 'trivia_quiz_game' # type: str

new_question_response = '\nQ: Which class of animals are newts members of?\n\n' + \
'* **A** Amphibian\n' + \
'* **B** Fish\n' + \
'* **C** Reptiles\n' + \
'* **D** Mammals\n' + \
'**reply**: <letter>'

test_question = {
'question': 'Question 1?',
'answers': {
'A': 'Correct',
'B': 'Incorrect 1',
'C': 'Incorrect 2',
'D': 'Incorrect 3'
},
'correct_letter': 'A'
}

test_question_message_content = '''
Q: Question 1?

* **A** Correct
* **B** Incorrect 1
* **C** Incorrect 2
* **D** Incorrect 3
**reply**: <letter>'''

test_question_message_widget = '{"widget_type": "zform", "extra_data": {"type": "choices", "heading": "Question 1?", "choices": [{"type": "multiple_choice", "short_name": "A", "long_name": "Correct", "reply": "A"}, {"type": "multiple_choice", "short_name": "B", "long_name": "Incorrect 1", "reply": "B"}, {"type": "multiple_choice", "short_name": "C", "long_name": "Incorrect 2", "reply": "C"}, {"type": "multiple_choice", "short_name": "D", "long_name": "Incorrect 3", "reply": "D"}]}}'

def test_question_not_available(self) -> None:
with self.mock_http_conversation('test_new_question'):
model = TriviaQuizGameModel()
# Exception
with self.assertRaises(NotAvailableException):
with mock_request_exception():
model.get_trivia_quiz()
# non-ok status code
with self.assertRaises(NotAvailableException):
with self.mock_http_conversation("test_status_code"):
model.get_trivia_quiz()

def test_validate_move(self) -> None:
with self.mock_http_conversation('test_new_question'):
model = TriviaQuizGameModel()
valid_moves = [
'A',
'B',
'C',
'D'
]
invalid_moves = [
'AA',
'1'
]
for valid_move in valid_moves:
self.assertTrue(model.validate_move(valid_move))
for invalid_move in invalid_moves:
self.assertFalse(model.validate_move(invalid_move))

def test_make_move(self) -> None:
with self.mock_http_conversation('test_new_question'):
model = TriviaQuizGameModel()
model.current_board = self.test_question
model.scores = {
0: 0,
1: 1
}
# Invalid move should raise BadMoveException
with self.assertRaises(BadMoveException):
model.make_move('AA', 0)
# Correct move should:
with self.mock_http_conversation('test_new_question'):
with patch('random.shuffle'):
move_data = model.make_move('A', 0)
# Increment score
self.assertEqual(model.scores[0], 1)
# Change question
self.assertEqual(model.current_board, read_bot_fixture_data("trivia_quiz_game", "test_new_question_dict"))
# Move data correct should be true
self.assertTrue(move_data['correct'])
# Move data score should be the same as model.scores[player_number]
self.assertEqual(move_data['score'], 1)
# Incorrect move should:
with self.mock_http_conversation('test_new_question'):
model.current_board = self.test_question
move_data = model.make_move('B', 1)
# Decrement score
self.assertEqual(model.scores[1], 0)
# Move data correct should be false
self.assertFalse(move_data['correct'])

def test_determine_game_over(self) -> None:
with self.mock_http_conversation('test_new_question'):
model = TriviaQuizGameModel()
model.scores = {
0: 0,
1: 5,
2: 1
}
self.assertEqual(model.determine_game_over(["Test 0", "Test 1", "Test 2"]), "Test 1")
model.scores = {
0: 0,
1: 4,
2: 1
}
self.assertIsNone(model.determine_game_over(["Test 0", "Test 1", "Test 2"]))

def test_message_handler_parse_board(self) -> None:
message_handler = TriviaQuizGameMessageHandler()
board_message_content, board_message_widget = message_handler.parse_board(self.test_question)
self.assertEqual(board_message_content, self.test_question_message_content)
self.assertEqual(json.loads(board_message_widget), json.loads(self.test_question_message_widget))

def test_message_handler_alert_move_message(self) -> None:
message_handler = TriviaQuizGameMessageHandler()
correct_responses = [
(("Test User", "A", {'correct': True, 'score': 5}), ":tada: Correct Test User (5 points)!"),
(("Test User", "B", {'correct': False, 'score': 1, 'correct_letter': "B"}), ":disappointed: Incorrect Test User (1 points). The correct answer was **B**")
]
for args, response in correct_responses:
self.assertEqual(message_handler.alert_move_message(*args), response)

def test_message_handler_get_player_color(self) -> None:
message_handler = TriviaQuizGameMessageHandler()
self.assertIsNone(message_handler.get_player_color(0))
Loading