You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

356 lines
15KB

  1. # -*- coding: utf-8 -*-
  2. import checks
  3. import discord
  4. import asyncio
  5. import requests
  6. import datetime
  7. from html import unescape
  8. from random import shuffle
  9. from collections import OrderedDict
  10. from discord.ext import commands
  11. class Trivia:
  12. """
  13. Trivia is based off the lovely https://opentdb.com made by PixelTail Games.
  14. This cog requires the bot account to be in the Roxbot Emoji Server to work.
  15. """
  16. def __init__(self, bot_client):
  17. # Get emoji objects here for the reactions. Basically to speedup the reactions for the game.
  18. self.bot = bot_client
  19. a_emoji = self.bot.get_emoji(419572828854026252)
  20. b_emoji = self.bot.get_emoji(419572828925329429)
  21. c_emoji = self.bot.get_emoji(419572829231775755)
  22. d_emoji = self.bot.get_emoji(419572828954820620)
  23. self.correct_emoji = self.bot.get_emoji(421526796392202240)
  24. self.incorrect_emoji = self.bot.get_emoji(421526796379488256)
  25. self.emojis = [a_emoji, b_emoji, c_emoji, d_emoji]
  26. self.games = {}
  27. self.error_colour = 0x992d22
  28. self.trivia_colour = 0x6f90f5
  29. # Game Functions
  30. def get_questions(self, amount=10):
  31. r = requests.get("https://opentdb.com/api.php?amount={}".format(amount))
  32. return r.json()
  33. def parse_question(self, question, counter):
  34. embed = discord.Embed(
  35. title=unescape(question["question"]),
  36. colour=discord.Colour(self.trivia_colour),
  37. description="")
  38. embed.set_author(name="Question {}".format(counter))
  39. embed.set_footer(text="Difficulty: {} | Category: {} | Time Left: ".format(question["category"], question["difficulty"].title()))
  40. if question["type"] == "boolean":
  41. # List of possible answers
  42. choices = ["True", "False"]
  43. correct = question["correct_answer"]
  44. # Get index of correct answer
  45. else:
  46. # Get possible answers and shuffle them in a list
  47. incorrect = question["incorrect_answers"]
  48. correct = unescape(question["correct_answer"])
  49. choices = [incorrect[0], incorrect[1], incorrect[2], correct]
  50. for answer in choices:
  51. choices[choices.index(answer)] = unescape(answer)
  52. shuffle(choices)
  53. # Then get the index of the correct answer
  54. correct = choices.index(correct)
  55. # Create output
  56. answers = ""
  57. for x in range(len(choices)):
  58. answers += "{} {}\n".format(str(self.emojis[x]), choices[x])
  59. return embed, answers, correct
  60. def calculate_scores(self, channel, time_asked):
  61. score_added = {}
  62. for user, time in self.games[channel.id]["correct_users"].items():
  63. seconds = (time - time_asked).total_seconds()
  64. seconds = round(seconds, 1)
  65. if seconds < 10:
  66. score = (10 - seconds) * 100
  67. score = int(round(score, -2))
  68. else:
  69. score = 50
  70. score_added[user] = score # This is just to display the amount of score added to a user
  71. return score_added
  72. def sort_leaderboard(self, scores):
  73. return OrderedDict(sorted(scores.items(), key=lambda x:x[1], reverse=True))
  74. def display_leaderboard(self, channel, scores_to_add):
  75. updated_scores = dict(self.games[channel.id]["players"])
  76. updated_scores = self.sort_leaderboard(updated_scores)
  77. output_scores = ""
  78. count = 1
  79. for scores in updated_scores:
  80. player = self.bot.get_user(scores)
  81. if not player:
  82. player = scores
  83. if scores in self.games[channel.id]["correct_users"]:
  84. emoji = self.correct_emoji
  85. else:
  86. emoji = self.incorrect_emoji
  87. output_scores += "{}) {}: {} {}".format(count, player.mention, emoji, updated_scores[scores])
  88. if scores in scores_to_add:
  89. output_scores += "(+{})\n".format(scores_to_add[scores])
  90. else:
  91. output_scores += "\n"
  92. count += 1
  93. return discord.Embed(title="Scores", description=output_scores, colour=discord.Colour(self.trivia_colour))
  94. async def add_question_reactions(self, message, question):
  95. if question["type"] == "boolean":
  96. amount = 2
  97. else:
  98. amount = 4
  99. for x in range(amount):
  100. await message.add_reaction(self.emojis[x])
  101. async def game(self, ctx, channel, questions):
  102. # For loop all the questions for the game, Maybe I should move the game dictionary here instead.
  103. question_count = 1
  104. for question in questions:
  105. # Parse question dictionary into something usable
  106. output, answers, correct = self.parse_question(question, question_count)
  107. self.games[channel.id]["correct_answer"] = correct
  108. # Send a message, add the emoji reactions, then edit in the question to avoid issues with answering before reactions are done.
  109. message = await ctx.send(embed=output)
  110. await self.add_question_reactions(message, question)
  111. output.description = answers
  112. footer = str(output.footer.text)
  113. output.set_footer(text=output.footer.text+str(20))
  114. await message.edit(embed=output)
  115. time_asked = datetime.datetime.now()
  116. # Set up variables for checking the question and if it's being answered
  117. players_yet_to_answer = list(self.games[channel.id]["players"].keys())
  118. self.games[channel.id]["current_question"] = message
  119. # Wait for answers
  120. for x in range(20):
  121. # Code for checking if there are still players in the game goes here to make sure nothing breaks.
  122. if not self.games[channel.id]["players"]:
  123. await message.clear_reactions()
  124. await ctx.send(embed=discord.Embed(description="Game ending due to lack of players.", colour=self.error_colour))
  125. return False
  126. for answered in self.games[channel.id]["players_answered"]:
  127. if answered in players_yet_to_answer:
  128. players_yet_to_answer.remove(answered)
  129. if not players_yet_to_answer:
  130. break
  131. else:
  132. output.set_footer(text=footer+str(20 - (x + 1)))
  133. await message.edit(embed=output)
  134. await asyncio.sleep(1)
  135. output.set_footer(text="")
  136. await message.edit(embed=output)
  137. # Clean up when answers have been submitted
  138. self.games[channel.id]["current_question"] = None
  139. await message.clear_reactions()
  140. # Display Correct answer and calculate and display scores.
  141. index = self.games[channel.id]["correct_answer"]
  142. embed = discord.Embed(
  143. colour=discord.Colour(0x1fb600),
  144. description="Correct answer is {} **{}**".format(
  145. self.emojis[index],
  146. unescape(question["correct_answer"])
  147. )
  148. )
  149. await ctx.send(embed=embed)
  150. # Scores
  151. scores_to_add = self.calculate_scores(channel, time_asked)
  152. for user in scores_to_add:
  153. self.games[channel.id]["players"][user] += scores_to_add[user]
  154. # Display scores
  155. await ctx.send(embed=self.display_leaderboard(channel, scores_to_add))
  156. # Display that
  157. # Final checks for next question
  158. self.games[channel.id]["correct_users"] = {}
  159. self.games[channel.id]["players_answered"] = []
  160. question_count += 1
  161. # Discord Events
  162. async def on_reaction_add(self, reaction, user):
  163. """Logic for answering a question"""
  164. time = datetime.datetime.now()
  165. if user == self.bot.user:
  166. return
  167. channel = reaction.message.channel
  168. message = reaction.message
  169. if channel.id in self.games:
  170. if user.id in self.games[channel.id]["players"] and message.id == self.games[channel.id]["current_question"].id:
  171. if reaction.emoji in self.emojis and user.id not in self.games[channel.id]["players_answered"]:
  172. self.games[channel.id]["players_answered"].append(user.id)
  173. if reaction.emoji == self.emojis[self.games[channel.id]["correct_answer"]]:
  174. self.games[channel.id]["correct_users"][user.id] = time
  175. return
  176. else:
  177. return await message.remove_reaction(reaction, user)
  178. else:
  179. return await message.remove_reaction(reaction, user)
  180. else:
  181. return
  182. # Commands
  183. @commands.group(aliases=["tr"])
  184. async def trivia(self, ctx):
  185. """Command group for the Roxbot Trivia game."""
  186. if ctx.invoked_subcommand == self.start and ctx.channel.id not in self.games:
  187. embed = discord.Embed(colour=0xDEADBF)
  188. embed.set_footer(text="Roxbot Trivia uses the Open Trivia DB, made and maintained by Pixeltail Games LLC. Find out more at https://opentdb.com/")
  189. embed.set_image(url="https://i.imgur.com/yhRVl9e.png")
  190. await ctx.send(embed=embed)
  191. elif ctx.invoked_subcommand == None:
  192. await ctx.invoke(self.about)
  193. @trivia.command()
  194. async def about(self, ctx):
  195. """He;p using the trivia game."""
  196. embed = discord.Embed(
  197. title="About Roxbot Trivia",
  198. description="Roxbot Trivia is a trivia game in *your* discord server. It's heavily inspired by Tower Unite's trivia game. (and even uses the same questions database!) To start, just type `{}trivia start`.".format(self.bot.command_prefix),
  199. colour=0xDEADBF)
  200. embed.add_field(name="How to Play", value="Once the game has started, questions will be asked and you will be given 20 seconds to answer them. To answer, react with the corrosponding emoji. Roxbot will only accept your first answer. Score is calculated by how quickly you can answer correctly, so make sure to be as quick as possible to win! Person with the most score at the end wins. Glhf!")
  201. embed.add_field(name="Can I have shorter or longer games?", value="Yes! You can change the length of the game by adding either short (5 questions) or long (15 questions) at the end of the start command. `{}trivia start short`. The default is 10 and this is the medium option.".format(self.bot.command_prefix))
  202. embed.add_field(name="Can I play with friends?", value="Yes! Trivia is best with friends. How else would friendships come to their untimely demise? You can only join a game during the 20 second waiting period after a game is started. Just type `{0}trivia join` and you're in! You can leave a game at anytime (even if its just you) by doing `{0}trivia leave`. If no players are in a game, the game will end and no one will win ;-;".format(self.bot.command_prefix))
  203. embed.set_footer(text="Roxbot Trivia uses the Open Trivia DB, made and maintained by Pixeltail Games LLC. Find out more at https://opentdb.com/")
  204. embed.set_image(url="https://i.imgur.com/yhRVl9e.png")
  205. return await ctx.send(embed=embed)
  206. @trivia.command()
  207. @commands.bot_has_permissions(manage_messages=True)
  208. async def start(self, ctx, amount = "medium"):
  209. """Starts a trivia game and waits 20 seconds for other people to join."""
  210. channel = ctx.channel
  211. player = ctx.author
  212. # Check if a game is already running and if so exit.
  213. if channel.id in self.games:
  214. # Game active in this channel already
  215. await ctx.send(embed=discord.Embed(description="A game is already being run in this channel.", colour=self.error_colour))
  216. await asyncio.sleep(2)
  217. return await ctx.message.delete()
  218. # Setup variables and wait for all players to join.
  219. # Length of game
  220. length = {"short": 5, "medium": 10, "long": 15}
  221. if amount not in length:
  222. amount = "medium"
  223. # Game Dictionaries
  224. game = {
  225. "players": {player.id: 0},
  226. "active": 0,
  227. "length": length[amount],
  228. "current_question": None,
  229. "players_answered": [],
  230. "correct_users": {},
  231. "correct_answer": ""
  232. }
  233. self.games[channel.id] = game
  234. # Waiting for players
  235. await ctx.send(embed=discord.Embed(description="Starting Roxbot Trivia! Starting in 20 seconds...", colour=self.trivia_colour))
  236. await asyncio.sleep(20)
  237. # Get questions
  238. questions = self.get_questions(length[amount])
  239. # Checks if there is any players to play the game still
  240. if not self.games[channel.id]["players"]:
  241. self.games.pop(channel.id)
  242. return await ctx.send(embed=discord.Embed(description="Abandoning game due to lack of players.", colour=self.error_colour))
  243. # Starts game
  244. self.games[channel.id]["active"] = 1
  245. await self.game(ctx, channel, questions["results"])
  246. # Game Ends
  247. # Some stuff here displaying score
  248. if self.games[channel.id]["players"]:
  249. final_scores = self.sort_leaderboard(self.games[channel.id]["players"])
  250. winner = self.bot.get_user(list(final_scores.keys())[0])
  251. winning_score = list(final_scores.values())[0]
  252. embed = discord.Embed(description="{} won with a score of {}".format(winner.mention, winning_score), colour=0xd4af3a)
  253. await ctx.send(embed=embed)
  254. self.games.pop(channel.id)
  255. @trivia.error
  256. async def trivia_err(self, ctx, error):
  257. # This is here to make sure that if an error occurs, the game will be removed from the dict and will safely exit the game, then raise the error like normal.
  258. self.games.pop(ctx.channel.id)
  259. await ctx.send(embed=discord.Embed(description="An error has occured ;-; Exiting the game...", colour=self.error_colour))
  260. raise error
  261. @trivia.command()
  262. async def join(self, ctx):
  263. """Joins a trivia game. Can only be done when a game is waiting for players to join. Not when a game is currently active."""
  264. channel = ctx.channel
  265. # Checks if game is in this channel. Then if one isn't active, then if the player has already joined.
  266. if channel.id in self.games:
  267. if not self.games[channel.id]["active"]:
  268. player = ctx.author
  269. if player.id not in self.games[channel.id]["players"]:
  270. self.games[channel.id]["players"][player.id] = 0
  271. return await ctx.send(embed=discord.Embed(description="Player {} joined the game".format(player.mention), colour=self.trivia_colour))
  272. # Failures
  273. else:
  274. return await ctx.send(embed=discord.Embed(description="You have already joined the game. If you want to leave, do `{}trivia leave`".format(self.bot.command_prefix), colour=self.error_colour))
  275. else:
  276. return await ctx.send(embed=discord.Embed(description="Game is already in progress.",colour=self.error_colour))
  277. else:
  278. return await ctx.send(embed=discord.Embed(description="Game isn't being played here.", colour=self.error_colour))
  279. @trivia.command()
  280. async def leave(self, ctx):
  281. """Leaves the game in this channel. Can be done anytime in the game."""
  282. channel = ctx.channel
  283. player = ctx.author
  284. # CAN LEAVE: Game is started or has been activated
  285. # CANT LEAVE: Game is not active or not in the game
  286. if channel.id in self.games:
  287. if player.id in self.games[channel.id]["players"]:
  288. self.games[channel.id]["players"].pop(player.id)
  289. await ctx.send(embed=discord.Embed(description="{} has left the game.".format(player.mention), colour=self.trivia_colour))
  290. else:
  291. await ctx.send(embed=discord.Embed(description="You are not in this game",
  292. colour=self.error_colour))
  293. else:
  294. await ctx.send(embed=discord.Embed(description="Game isn't being played here.", colour=self.error_colour))
  295. @checks.is_admin_or_mod()
  296. @trivia.command()
  297. async def kick(self, ctx, user: discord.Member):
  298. """Mod command to kick users out of the game. Useful if a user is AFK."""
  299. channel = ctx.channel
  300. player = user
  301. if channel.id in self.games:
  302. if player.id in self.games[channel.id]["players"]:
  303. self.games[channel.id]["players"].pop(player.id)
  304. await ctx.send(embed=discord.Embed(description="{} has been kicked from the game.".format(player.mention), colour=self.trivia_colour))
  305. else:
  306. await ctx.send(embed=discord.Embed(description="This user is not in the game",
  307. colour=self.error_colour))
  308. else:
  309. await ctx.send(embed=discord.Embed(description="Game isn't being played here.", colour=self.error_colour))
  310. def setup(Bot):
  311. Bot.add_cog(Trivia(Bot))