diff options
28 files changed, 2271 insertions, 81 deletions
@@ -13,6 +13,8 @@ logmatic-python = "*" aiohttp = "<2.3.0,>=2.0.0" websockets = ">=4.0,<5.0" yarl = "==1.1.1" +fuzzywuzzy = "*" +python-levenshtein = "*" [dev-packages] "flake8" = "*" diff --git a/Pipfile.lock b/Pipfile.lock index e0e953856..58cf4b146 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0d592e6949aad4280702fcfa059e2db4e1a84f6b6098d4ec58eb317a68061f4f" + "sha256": "dffbd618e2339f4e3e7b0e9a21bf27cfbd676173b123f52c9fcf1f11cfc5a6fd" }, "pipfile-spec": 6, "requires": { @@ -75,6 +75,14 @@ "index": "pypi", "version": "==0.19.2" }, + "fuzzywuzzy": { + "hashes": [ + "sha256:d40c22d2744dff84885b30bbfc07fab7875f641d070374331777a4d1808b8d4e", + "sha256:ecf490216fb4d76b558a03042ff8f45a8782f17326caca1384d834cbaa2c7e6f" + ], + "index": "pypi", + "version": "==0.16.0" + }, "idna": { "hashes": [ "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f", @@ -147,6 +155,13 @@ ], "version": "==0.1.8" }, + "python-levenshtein": { + "hashes": [ + "sha256:033a11de5e3d19ea25c9302d11224e1a1898fe5abd23c61c7c360c25195e3eb1" + ], + "index": "pypi", + "version": "==0.12.0" + }, "sympy": { "hashes": [ "sha256:ac5b57691bc43919dcc21167660a57cc51797c28a4301a6144eff07b751216a4" @@ -352,10 +367,10 @@ }, "pbr": { "hashes": [ - "sha256:4e8a0ed6a8705a26768f4c3da26026013b157821fe5f95881599556ea9d91c19", - "sha256:dae4aaa78eafcad10ce2581fc34d694faa616727837fd8e55c1a00951ad6744f" + "sha256:680bf5ba9b28dd56e08eb7c267991a37c7a5f90a92c2e07108829931a50ff80a", + "sha256:6874feb22334a1e9a515193cba797664e940b763440c88115009ec323a7f2df5" ], - "version": "==4.0.2" + "version": "==4.0.3" }, "pycodestyle": { "hashes": [ diff --git a/bot/__init__.py b/bot/__init__.py index c4f99216a..afc16e37f 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -192,6 +192,7 @@ def _get_word(self) -> str: # Args handling new_args = [] + if args: # Force args into container if not isinstance(args, tuple): @@ -227,6 +228,7 @@ def _get_word(self) -> str: # Iterate through the buffer and determine pos = 0 + current = None while not self.eof: try: current = self.buffer[self.index + pos] diff --git a/bot/__main__.py b/bot/__main__.py index 0e2041bdb..6c115f40c 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -59,6 +59,7 @@ bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.fun") bot.load_extension("bot.cogs.hiphopify") +bot.load_extension("bot.cogs.snakes") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.verification") diff --git a/bot/cogs/events.py b/bot/cogs/events.py index 0e10ba25b..d8f5edd33 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -7,9 +7,13 @@ from discord.ext.commands import ( NoPrivateMessage, UserInputError ) -from bot.constants import DEVLOG_CHANNEL, PYTHON_GUILD, SITE_API_KEY, SITE_API_USER_URL +from bot.constants import ( + DEBUG_MODE, DEVLOG_CHANNEL, PYTHON_GUILD, + SITE_API_KEY, SITE_API_URL +) log = logging.getLogger(__name__) +USERS_URL = f"{SITE_API_URL}/bot/users" class Events: @@ -24,13 +28,13 @@ class Events: try: if replace_all: response = await self.bot.http_session.post( - url=SITE_API_USER_URL, + url=USERS_URL, json=list(users), headers={"X-API-Key": SITE_API_KEY} ) else: response = await self.bot.http_session.put( - url=SITE_API_USER_URL, + url=USERS_URL, json=list(users), headers={"X-API-Key": SITE_API_KEY} ) @@ -43,7 +47,8 @@ class Events: async def send_delete_users(self, *users): try: response = await self.bot.http_session.delete( - url=SITE_API_USER_URL, + url=USERS_URL, + json=list(users), headers={"X-API-Key": SITE_API_KEY} ) @@ -88,7 +93,7 @@ class Events: f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" ) raise e.original - log.error(f"COMMAND ERROR: '{e}'") + raise e async def on_ready(self): users = [] @@ -124,9 +129,10 @@ class Events: name=key, value=str(value) ) - await self.bot.get_channel(DEVLOG_CHANNEL).send( - embed=embed - ) + if not DEBUG_MODE: + await self.bot.get_channel(DEVLOG_CHANNEL).send( + embed=embed + ) async def on_member_update(self, before: Member, after: Member): if before.roles == after.roles and before.name == after.name and before.discriminator == after.discriminator: diff --git a/bot/cogs/hiphopify.py b/bot/cogs/hiphopify.py index c28c9bdfc..bb8dc1300 100644 --- a/bot/cogs/hiphopify.py +++ b/bot/cogs/hiphopify.py @@ -8,7 +8,7 @@ from discord.ext.commands import AutoShardedBot, Context, command from bot.constants import ( ADMIN_ROLE, MODERATOR_ROLE, MOD_LOG_CHANNEL, NEGATIVE_REPLIES, OWNER_ROLE, POSITIVE_REPLIES, - SITE_API_HIPHOPIFY_URL, SITE_API_KEY + SITE_API_KEY, SITE_API_URL ) from bot.decorators import with_role @@ -23,6 +23,7 @@ class Hiphopify: def __init__(self, bot: AutoShardedBot): self.bot = bot self.headers = {"X-API-KEY": SITE_API_KEY} + self.url = f"{SITE_API_URL}/bot/hiphopify" async def on_member_update(self, before, after): """ @@ -42,7 +43,7 @@ class Hiphopify: ) response = await self.bot.http_session.get( - SITE_API_HIPHOPIFY_URL, + self.url, headers=self.headers, params={"user_id": str(before.id)} ) @@ -104,7 +105,7 @@ class Hiphopify: params["forced_nick"] = forced_nick response = await self.bot.http_session.post( - SITE_API_HIPHOPIFY_URL, + self.url, headers=self.headers, json=params ) @@ -167,7 +168,7 @@ class Hiphopify: embed.colour = Colour.blurple() response = await self.bot.http_session.delete( - SITE_API_HIPHOPIFY_URL, + self.url, headers=self.headers, json={"user_id": str(member.id)} ) diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 6d46a3fb4..60403ec2d 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -3,7 +3,7 @@ import logging from discord import Embed from discord.ext.commands import AutoShardedBot -from bot.constants import DEVLOG_CHANNEL +from bot.constants import DEBUG_MODE, DEVLOG_CHANNEL log = logging.getLogger(__name__) @@ -26,7 +26,8 @@ class Logging: icon_url="https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle.png" ) - await self.bot.get_channel(DEVLOG_CHANNEL).send(embed=embed) + if not DEBUG_MODE: + await self.bot.get_channel(DEVLOG_CHANNEL).send(embed=embed) def setup(bot): diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py new file mode 100644 index 000000000..3a5d79148 --- /dev/null +++ b/bot/cogs/snakes.py @@ -0,0 +1,1214 @@ +import asyncio +import colorsys +import logging +import os +import random +import re +import string +import textwrap +import urllib +from functools import partial +from io import BytesIO +from typing import Any, Dict + +import aiohttp +import async_timeout +from discord import Colour, Embed, File, Member, Message, Reaction +from discord.ext.commands import AutoShardedBot, BadArgument, Context, bot_has_permissions, command +from PIL import Image, ImageDraw, ImageFont + +from bot.constants import ( + ERROR_REPLIES, OMDB_API_KEY, SITE_API_KEY, + SITE_API_URL, YOUTUBE_API_KEY +) +from bot.converters import Snake +from bot.decorators import locked +from bot.utils.snakes import hatching, perlin, perlinsneks, sal + +log = logging.getLogger(__name__) + +# region: Constants +# Color +SNAKE_COLOR = 0x399600 + +# Antidote constants +SYRINGE_EMOJI = "\U0001F489" # :syringe: +PILL_EMOJI = "\U0001F48A" # :pill: +HOURGLASS_EMOJI = "\u231B" # :hourglass: +CROSSBONES_EMOJI = "\u2620" # :skull_crossbones: +ALEMBIC_EMOJI = "\u2697" # :alembic: +TICK_EMOJI = "\u2705" # :white_check_mark: - Correct peg, correct hole +CROSS_EMOJI = "\u274C" # :x: - Wrong peg, wrong hole +BLANK_EMOJI = "\u26AA" # :white_circle: - Correct peg, wrong hole +HOLE_EMOJI = "\u2B1C" # :white_square: - Used in guesses +EMPTY_UNICODE = "\u200b" # literally just an empty space + +ANTIDOTE_EMOJI = ( + SYRINGE_EMOJI, + PILL_EMOJI, + HOURGLASS_EMOJI, + CROSSBONES_EMOJI, + ALEMBIC_EMOJI, +) + +# Quiz constants +ANSWERS_EMOJI = { + "a": "\U0001F1E6", # :regional_indicator_a: 🇦 + "b": "\U0001F1E7", # :regional_indicator_b: 🇧 + "c": "\U0001F1E8", # :regional_indicator_c: 🇨 + "d": "\U0001F1E9", # :regional_indicator_d: 🇩 +} + +ANSWERS_EMOJI_REVERSE = { + "\U0001F1E6": "A", # :regional_indicator_a: 🇦 + "\U0001F1E7": "B", # :regional_indicator_b: 🇧 + "\U0001F1E8": "C", # :regional_indicator_c: 🇨 + "\U0001F1E9": "D", # :regional_indicator_d: 🇩 +} + +# Zzzen of pythhhon constant +ZEN = """ +Beautiful is better than ugly. +Explicit is better than implicit. +Simple is better than complex. +Complex is better than complicated. +Flat is better than nested. +Sparse is better than dense. +Readability counts. +Special cases aren't special enough to break the rules. +Although practicality beats purity. +Errors should never pass silently. +Unless explicitly silenced. +In the face of ambiguity, refuse the temptation to guess. +There should be one-- and preferably only one --obvious way to do it. +Now is better than never. +Although never is often better than *right* now. +If the implementation is hard to explain, it's a bad idea. +If the implementation is easy to explain, it may be a good idea. +""" + +# Max messages to train snake_chat on +MSG_MAX = 100 + +# get_snek constants +URL = "https://en.wikipedia.org/w/api.php?" + +# snake guess responses +INCORRECT_GUESS = ( + "Nope, that's not what it is.", + "Not quite.", + "Not even close.", + "Terrible guess.", + "Nnnno.", + "Dude. No.", + "I thought everyone knew this one.", + "Guess you suck at snakes.", + "Bet you feel stupid now.", + "Hahahaha, no.", + "Did you hit the wrong key?" +) + +CORRECT_GUESS = ( + "**WRONG**. Wait, no, actually you're right.", + "Yeah, you got it!", + "Yep, that's exactly what it is.", + "Uh-huh. Yep yep yep.", + "Yeah that's right.", + "Yup. How did you know that?", + "Are you a herpetologist?", + "Sure, okay, but I bet you can't pronounce it.", + "Are you cheating?" +) + +# snake card consts +CARD = { + "top": Image.open("bot/resources/snake_cards/card_top.png"), + "frame": Image.open("bot/resources/snake_cards/card_frame.png"), + "bottom": Image.open("bot/resources/snake_cards/card_bottom.png"), + "backs": [ + Image.open(f"bot/resources/snake_cards/backs/{file}") + for file in os.listdir("bot/resources/snake_cards/backs") + ], + "font": ImageFont.truetype("bot/resources/snake_cards/expressway.ttf", 20) +} +# endregion + + +class Snakes: + """ + Commands related to snakes. These were created by our + community during the first code jam. + + More information can be found in the code-jam-1 repo. + + https://github.com/discord-python/code-jam-1 + """ + + wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL) + valid_image_extensions = ('gif', 'png', 'jpeg', 'jpg', 'webp') + + def __init__(self, bot: AutoShardedBot): + self.active_sal = {} + self.bot = bot + self.headers = {"X-API-KEY": SITE_API_KEY} + + # Build API urls. + self.quiz_url = f"{SITE_API_URL}/bot/snake_quiz" + self.facts_url = f"{SITE_API_URL}/bot/snake_facts" + self.names_url = f"{SITE_API_URL}/bot/snake_names" + self.idioms_url = f"{SITE_API_URL}/bot/snake_idioms" + + # region: Helper methods + @staticmethod + def _beautiful_pastel(hue): + """ + Returns random bright pastels. + """ + + light = random.uniform(0.7, 0.85) + saturation = 1 + + rgb = colorsys.hls_to_rgb(hue, light, saturation) + hex_rgb = "" + + for part in rgb: + value = int(part * 0xFF) + hex_rgb += f"{value:02x}" + + return int(hex_rgb, 16) + + @staticmethod + def _generate_card(buffer: BytesIO, content: dict) -> BytesIO: + """ + Generate a card from snake information. + + Written by juan and Someone during the first code jam. + """ + + snake = Image.open(buffer) + + # Get the size of the snake icon, configure the height of the image box (yes, it changes) + icon_width = 347 # Hardcoded, not much i can do about that + icon_height = int((icon_width / snake.width) * snake.height) + frame_copies = icon_height // CARD['frame'].height + 1 + snake.thumbnail((icon_width, icon_height)) + + # Get the dimensions of the final image + main_height = icon_height + CARD['top'].height + CARD['bottom'].height + main_width = CARD['frame'].width + + # Start creating the foreground + foreground = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) + foreground.paste(CARD['top'], (0, 0)) + + # Generate the frame borders to the correct height + for offset in range(frame_copies): + position = (0, CARD['top'].height + offset * CARD['frame'].height) + foreground.paste(CARD['frame'], position) + + # Add the image and bottom part of the image + foreground.paste(snake, (36, CARD['top'].height)) # Also hardcoded :( + foreground.paste(CARD['bottom'], (0, CARD['top'].height + icon_height)) + + # Setup the background + back = random.choice(CARD['backs']) + back_copies = main_height // back.height + 1 + full_image = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) + + # Generate the tiled background + for offset in range(back_copies): + full_image.paste(back, (16, 16 + offset * back.height)) + + # Place the foreground onto the final image + full_image.paste(foreground, (0, 0), foreground) + + # Get the first two sentences of the info + description = '.'.join(content['info'].split(".")[:2]) + '.' + + # Setup positioning variables + margin = 36 + offset = CARD['top'].height + icon_height + margin + + # Create blank rectangle image which will be behind the text + rectangle = Image.new( + "RGBA", + (main_width, main_height), + (0, 0, 0, 0) + ) + + # Draw a semi-transparent rectangle on it + rect = ImageDraw.Draw(rectangle) + rect.rectangle( + (margin, offset, main_width - margin, main_height - margin), + fill=(63, 63, 63, 128) + ) + + # Paste it onto the final image + full_image.paste(rectangle, (0, 0), mask=rectangle) + + # Draw the text onto the final image + draw = ImageDraw.Draw(full_image) + for line in textwrap.wrap(description, 36): + draw.text([margin + 4, offset], line, font=CARD['font']) + offset += CARD['font'].getsize(line)[1] + + # Get the image contents as a BufferIO object + buffer = BytesIO() + full_image.save(buffer, 'PNG') + buffer.seek(0) + + return buffer + + @staticmethod + def _snakify(message): + """ + Sssnakifffiesss a sstring. + """ + + # Replace fricatives with exaggerated snake fricatives. + simple_fricatives = [ + "f", "s", "z", "h", + "F", "S", "Z", "H", + ] + complex_fricatives = [ + "th", "sh", "Th", "Sh" + ] + + for letter in simple_fricatives: + if letter.islower(): + message = message.replace(letter, letter * random.randint(2, 4)) + else: + message = message.replace(letter, (letter * random.randint(2, 4)).title()) + + for fricative in complex_fricatives: + message = message.replace(fricative, fricative[0] + fricative[1] * random.randint(2, 4)) + + return message + + async def _fetch(self, session, url, params=None): + """ + Asyncronous web request helper method. + """ + + if params is None: + params = {} + + async with async_timeout.timeout(10): + async with session.get(url, params=params) as response: + return await response.json() + + def _get_random_long_message(self, messages, retries=10): + """ + Fetch a message that's at least 3 words long, + but only if it is possible to do so in retries + attempts. Else, just return whatever the last + message is. + """ + + long_message = random.choice(messages) + if len(long_message.split()) < 3 or retries <= 0: + return self._get_random_long_message(messages, retries - 1) + + return long_message + + async def _get_snek(self, name: str) -> Dict[str, Any]: + """ + Goes online and fetches all the data from a wikipedia article + about a snake. Builds a dict that the .get() method can use. + + Created by Ava and eivl. + + :param name: The name of the snake to get information for - omit for a random snake + :return: A dict containing information on a snake + """ + + snake_info = {} + + async with aiohttp.ClientSession() as session: + params = { + 'format': 'json', + 'action': 'query', + 'list': 'search', + 'srsearch': name, + 'utf8': '', + 'srlimit': '1', + } + + json = await self._fetch(session, URL, params=params) + + # wikipedia does have a error page + try: + pageid = json["query"]["search"][0]["pageid"] + except KeyError: + # Wikipedia error page ID(?) + pageid = 41118 + except IndexError: + return None + + params = { + 'format': 'json', + 'action': 'query', + 'prop': 'extracts|images|info', + 'exlimit': 'max', + 'explaintext': '', + 'inprop': 'url', + 'pageids': pageid + } + + json = await self._fetch(session, URL, params=params) + + # constructing dict - handle exceptions later + try: + snake_info["title"] = json["query"]["pages"][f"{pageid}"]["title"] + snake_info["extract"] = json["query"]["pages"][f"{pageid}"]["extract"] + snake_info["images"] = json["query"]["pages"][f"{pageid}"]["images"] + snake_info["fullurl"] = json["query"]["pages"][f"{pageid}"]["fullurl"] + snake_info["pageid"] = json["query"]["pages"][f"{pageid}"]["pageid"] + except KeyError: + snake_info["error"] = True + + if snake_info["images"]: + i_url = 'https://commons.wikimedia.org/wiki/Special:FilePath/' + image_list = [] + map_list = [] + thumb_list = [] + + # Wikipedia has arbitrary images that are not snakes + banned = [ + 'Commons-logo.svg', + 'Red%20Pencil%20Icon.png', + 'distribution', + 'The%20Death%20of%20Cleopatra%20arthur.jpg', + 'Head%20of%20holotype', + 'locator', + 'Woma.png', + '-map.', + '.svg', + 'ange.', + 'Adder%20(PSF).png' + ] + + for image in snake_info["images"]: + # images come in the format of `File:filename.extension` + file, sep, filename = image["title"].partition(':') + filename = filename.replace(" ", "%20") # Wikipedia returns good data! + + if not filename.startswith('Map'): + if any(ban in filename for ban in banned): + pass + else: + image_list.append(f"{i_url}{filename}") + thumb_list.append(f"{i_url}{filename}?width=100") + else: + map_list.append(f"{i_url}{filename}") + + snake_info["image_list"] = image_list + snake_info["map_list"] = map_list + snake_info["thumb_list"] = thumb_list + snake_info["name"] = name + + match = self.wiki_brief.match(snake_info['extract']) + info = match.group(1) if match else None + + if info: + info = info.replace("\n", "\n\n") # Give us some proper paragraphs. + + snake_info["info"] = info + + return snake_info + + async def _get_snake_name(self) -> Dict[str, str]: + """ + Gets a random snake name. + :return: A random snake name, as a string. + """ + + response = await self.bot.http_session.get(self.names_url, headers=self.headers) + name_data = await response.json() + + return name_data + + async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list): + """ + Validate the answer using a reaction event loop + :return: + """ + + def predicate(reaction, user): + """ + Test if the the answer is valid and can be evaluated. + """ + return ( + reaction.message.id == message.id # The reaction is attached to the question we asked. + and user == ctx.author # It's the user who triggered the quiz. + and str(reaction.emoji) in ANSWERS_EMOJI.values() # The reaction is one of the options. + ) + + for emoji in ANSWERS_EMOJI.values(): + await message.add_reaction(emoji) + + # Validate the answer + try: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=45.0, check=predicate) + except asyncio.TimeoutError: + await ctx.channel.send(f"You took too long. The correct answer was **{options[answer]}**.") + await message.clear_reactions() + return + + if str(reaction.emoji) == ANSWERS_EMOJI[answer]: + await ctx.send(f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**.") + else: + await ctx.send( + f"{random.choice(INCORRECT_GUESS)} The correct answer was **{options[answer]}**." + ) + + await message.clear_reactions() + # endregion + + # region: Commands + @bot_has_permissions(manage_messages=True) + @command(name="snakes.antidote()", aliases=["snakes.antidote"]) + @locked() + async def antidote(self, ctx: Context): + """ + Antidote - Can you create the antivenom before the patient dies? + + Rules: You have 4 ingredients for each antidote, you only have 10 attempts + Once you synthesize the antidote, you will be presented with 4 markers + Tick: This means you have a CORRECT ingredient in the CORRECT position + Circle: This means you have a CORRECT ingredient in the WRONG position + Cross: This means you have a WRONG ingredient in the WRONG position + + Info: The game automatically ends after 5 minutes inactivity. + You should only use each ingredient once. + + This game was created by Lord Bisk and Runew0lf. + """ + + def predicate(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + + return ( + all(( + reaction_.message.id == board_id.id, # Reaction is on this message + reaction_.emoji in ANTIDOTE_EMOJI, # Reaction is one of the pagination emotes + user_.id != self.bot.user.id, # Reaction was not made by the Bot + user_.id == ctx.author.id # Reaction was made by author + )) + ) + + # Initialize variables + antidote_tries = 0 + antidote_guess_count = 0 + antidote_guess_list = [] + guess_result = [] + board = [] + page_guess_list = [] + page_result_list = [] + win = False + + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + + # Generate answer + antidote_answer = list(ANTIDOTE_EMOJI) # Duplicate list, not reference it + random.shuffle(antidote_answer) + antidote_answer.pop() + + # Begin initial board building + for i in range(0, 10): + page_guess_list.append(f"{HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI}") + page_result_list.append(f"{CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI}") + board.append(f"`{i+1:02d}` " + f"{page_guess_list[i]} - " + f"{page_result_list[i]}") + board.append(EMPTY_UNICODE) + antidote_embed.add_field(name="10 guesses remaining", value="\n".join(board)) + board_id = await ctx.send(embed=antidote_embed) # Display board + + # Add our player reactions + for emoji in ANTIDOTE_EMOJI: + await board_id.add_reaction(emoji) + + # Begin main game loop + while not win and antidote_tries < 10: + try: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=300, check=predicate) + except asyncio.TimeoutError: + log.debug("Antidote timed out waiting for a reaction") + break # We're done, no reactions for the last 5 minutes + + if antidote_tries < 10: + if antidote_guess_count < 4: + if reaction.emoji in ANTIDOTE_EMOJI: + antidote_guess_list.append(reaction.emoji) + antidote_guess_count += 1 + + if antidote_guess_count == 4: # Guesses complete + antidote_guess_count = 0 + page_guess_list[antidote_tries] = " ".join(antidote_guess_list) + + # Now check guess + for i in range(0, len(antidote_answer)): + if antidote_guess_list[i] == antidote_answer[i]: + guess_result.append(TICK_EMOJI) + elif antidote_guess_list[i] in antidote_answer: + guess_result.append(BLANK_EMOJI) + else: + guess_result.append(CROSS_EMOJI) + guess_result.sort() + page_result_list[antidote_tries] = " ".join(guess_result) + + # Rebuild the board + board = [] + for i in range(0, 10): + board.append(f"`{i+1:02d}` " + f"{page_guess_list[i]} - " + f"{page_result_list[i]}") + board.append(EMPTY_UNICODE) + + # Remove Reactions + for emoji in antidote_guess_list: + await board_id.remove_reaction(emoji, user) + + if antidote_guess_list == antidote_answer: + win = True + + antidote_tries += 1 + guess_result = [] + antidote_guess_list = [] + + antidote_embed.clear_fields() + antidote_embed.add_field(name=f"{10 - antidote_tries} " + f"guesses remaining", + value="\n".join(board)) + # Redisplay the board + await board_id.edit(embed=antidote_embed) + + # Winning / Ending Screen + if win is True: + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + antidote_embed.set_image(url="https://i.makeagif.com/media/7-12-2015/Cj1pts.gif") + antidote_embed.add_field(name=f"You have created the snake antidote!", + value=f"The solution was: {' '.join(antidote_answer)}\n" + f"You had {10 - antidote_tries} tries remaining.") + await board_id.edit(embed=antidote_embed) + else: + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + antidote_embed.set_image(url="https://media.giphy.com/media/ceeN6U57leAhi/giphy.gif") + antidote_embed.add_field(name=EMPTY_UNICODE, + value=f"Sorry you didnt make the antidote in time.\n" + f"The formula was {' '.join(antidote_answer)}") + await board_id.edit(embed=antidote_embed) + + log.debug("Ending pagination and removing all reactions...") + await board_id.clear_reactions() + + @command(name="snakes.draw()", aliases=["snakes.draw"]) + async def draw(self, ctx: Context): + """ + Draws a random snek using Perlin noise + + Written by Momo and kel. + Modified by juan and lemon. + """ + + with ctx.typing(): + + # Generate random snake attributes + width = random.randint(6, 10) + length = random.randint(15, 22) + random_hue = random.random() + snek_color = self._beautiful_pastel(random_hue) + text_color = self._beautiful_pastel((random_hue + 0.5) % 1) + bg_color = ( + random.randint(32, 50), + random.randint(32, 50), + random.randint(50, 70), + ) + + # Get a snake idiom from the API + response = await self.bot.http_session.get(self.idioms_url, headers=self.headers) + text = await response.json() + + # Build and send the snek + factory = perlin.PerlinNoiseFactory(dimension=1, octaves=2) + image_frame = perlinsneks.create_snek_frame( + factory, + snake_width=width, + snake_length=length, + snake_color=snek_color, + text=text, + text_color=text_color, + bg_color=bg_color + ) + png_bytes = perlinsneks.frame_to_png_bytes(image_frame) + + file = File(png_bytes, filename='snek.png') + + await ctx.send(file=file) + + @command(name="snakes.get()", aliases=["snakes.get"]) + @bot_has_permissions(manage_messages=True) + @locked() + async def get(self, ctx: Context, name: Snake = None): + """ + Fetches information about a snake from Wikipedia. + :param ctx: Context object passed from discord.py + :param name: Optional, the name of the snake to get information for - omit for a random snake + + Created by Ava and eivl. + """ + + with ctx.typing(): + if name is None: + name = await Snake.random() + + if isinstance(name, dict): + data = name + else: + data = await self._get_snek(name) + + if data.get('error'): + return await ctx.send('Could not fetch data from Wikipedia.') + + description = data["info"] + + # Shorten the description if needed + if len(description) > 1000: + description = description[:1000] + last_newline = description.rfind("\n") + if last_newline > 0: + description = description[:last_newline] + + # Strip and add the Wiki link. + if "fullurl" in data: + description = description.strip("\n") + description += f"\n\nRead more on [Wikipedia]({data['fullurl']})" + + # Build and send the embed. + embed = Embed( + title=data.get("title", data.get('name')), + description=description, + colour=0x59982F, + ) + + emoji = 'https://emojipedia-us.s3.amazonaws.com/thumbs/60/google/3/snake_1f40d.png' + image = next((url for url in data['image_list'] if url.endswith(self.valid_image_extensions)), emoji) + embed.set_image(url=image) + + await ctx.send(embed=embed) + + @command(name="snakes.guess()", aliases=["snakes.guess", "identify"]) + @locked() + async def guess(self, ctx): + """ + Snake identifying game! + + Made by Ava and eivl. + Modified by lemon. + """ + + with ctx.typing(): + + image = None + + while image is None: + snakes = [await Snake.random() for _ in range(4)] + snake = random.choice(snakes) + answer = "abcd"[snakes.index(snake)] + + data = await self._get_snek(snake) + + image = next((url for url in data['image_list'] if url.endswith(self.valid_image_extensions)), None) + + embed = Embed( + title='Which of the following is the snake in the image?', + description="\n".join(f"{'ABCD'[snakes.index(snake)]}: {snake}" for snake in snakes), + colour=SNAKE_COLOR + ) + embed.set_image(url=image) + + guess = await ctx.send(embed=embed) + options = {f"{'abcd'[snakes.index(snake)]}": snake for snake in snakes} + await self._validate_answer(ctx, guess, answer, options) + + @command(name="snakes.hatch()", aliases=["snakes.hatch", "hatch"]) + async def hatch(self, ctx: Context): + """ + Hatches your personal snake + + Written by Momo and kel. + """ + + # Pick a random snake to hatch. + snake_name = random.choice(list(hatching.snakes.keys())) + snake_image = hatching.snakes[snake_name] + + # Hatch the snake + message = await ctx.channel.send(embed=Embed(description="Hatching your snake :snake:...")) + await asyncio.sleep(1) + + for stage in hatching.stages: + hatch_embed = Embed(description=stage) + await message.edit(embed=hatch_embed) + await asyncio.sleep(1) + await asyncio.sleep(1) + await message.delete() + + # Build and send the embed. + my_snake_embed = Embed(description=":tada: Congrats! You hatched: **{0}**".format(snake_name)) + my_snake_embed.set_thumbnail(url=snake_image) + my_snake_embed.set_footer( + text=" Owner: {0}#{1}".format(ctx.message.author.name, ctx.message.author.discriminator) + ) + + await ctx.channel.send(embed=my_snake_embed) + + @command(name="snakes.movie()", aliases=["snakes.movie"]) + async def movie(self, ctx: Context): + """ + Gets a random snake-related movie from OMDB. + + Written by Samuel. + Modified by gdude. + """ + + url = "http://www.omdbapi.com/" + page = random.randint(1, 27) + + response = await self.bot.http_session.get( + url, + params={ + "s": "snake", + "page": page, + "type": "movie", + "apikey": OMDB_API_KEY + } + ) + data = await response.json() + movie = random.choice(data["Search"])["imdbID"] + + response = await self.bot.http_session.get( + url, + params={ + "i": movie, + "apikey": OMDB_API_KEY + } + ) + data = await response.json() + + embed = Embed( + title=data["Title"], + color=SNAKE_COLOR + ) + + del data["Response"], data["imdbID"], data["Title"] + + for key, value in data.items(): + if not value or value == "N/A" or key in ("Response", "imdbID", "Title", "Type"): + continue + + if key == "Ratings": # [{'Source': 'Internet Movie Database', 'Value': '7.6/10'}] + rating = random.choice(value) + + if rating["Source"] != "Internet Movie Database": + embed.add_field(name=f"Rating: {rating['Source']}", value=rating["Value"]) + + continue + + if key == "Poster": + embed.set_image(url=value) + continue + + elif key == "imdbRating": + key = "IMDB Rating" + + elif key == "imdbVotes": + key = "IMDB Votes" + + embed.add_field(name=key, value=value, inline=True) + + embed.set_footer(text="Data provided by the OMDB API") + + await ctx.channel.send( + embed=embed + ) + + @command(name="snakes.quiz()", aliases=["snakes.quiz"]) + @locked() + async def quiz(self, ctx: Context): + """ + Asks a snake-related question in the chat and validates the user's guess. + + This was created by Mushy and Cardium, + and modified by Urthas and lemon. + """ + + # Prepare a question. + response = await self.bot.http_session.get(self.quiz_url, headers=self.headers) + question = await response.json() + answer = question["answerkey"] + options = {key: question["options"][key] for key in ANSWERS_EMOJI.keys()} + + # Build and send the embed. + embed = Embed( + color=SNAKE_COLOR, + title=question["question"], + description="\n".join( + [f"**{key.upper()}**: {answer}" for key, answer in options.items()] + ) + ) + + quiz = await ctx.channel.send("", embed=embed) + await self._validate_answer(ctx, quiz, answer, options) + + @command(name="snakes.name()", aliases=["snakes.name", "snakes.name_gen", "snakes.name_gen()"]) + async def random_snake_name(self, ctx: Context, name: str = None): + """ + Slices the users name at the last vowel (or second last if the name + ends with a vowel), and then combines it with a random snake name, + which is sliced at the first vowel (or second if the name starts with + a vowel). + + If the name contains no vowels, it just appends the snakename + to the end of the name. + + Examples: + lemon + anaconda = lemoconda + krzsn + anaconda = krzsnconda + gdude + anaconda = gduconda + aperture + anaconda = apertuconda + lucy + python = luthon + joseph + taipan = joseipan + + This was written by Iceman, and modified for inclusion into the bot by lemon. + """ + + snake_name = await self._get_snake_name() + snake_name = snake_name['name'] + snake_prefix = "" + + # Set aside every word in the snake name except the last. + if " " in snake_name: + snake_prefix = " ".join(snake_name.split()[:-1]) + snake_name = snake_name.split()[-1] + + # If no name is provided, use whoever called the command. + if name: + user_name = name + else: + user_name = ctx.author.display_name + + # Get the index of the vowel to slice the username at + user_slice_index = len(user_name) + for index, char in enumerate(reversed(user_name)): + if index == 0: + continue + if char.lower() in "aeiouy": + user_slice_index -= index + break + + # Now, get the index of the vowel to slice the snake_name at + snake_slice_index = 0 + for index, char in enumerate(snake_name): + if index == 0: + continue + if char.lower() in "aeiouy": + snake_slice_index = index + 1 + break + + # Combine! + snake_name = snake_name[snake_slice_index:] + user_name = user_name[:user_slice_index] + result = f"{snake_prefix} {user_name}{snake_name}" + result = string.capwords(result) + + # Embed and send + embed = Embed( + title="Snake name", + description=f"Your snake-name is **{result}**", + color=SNAKE_COLOR + ) + + return await ctx.send(embed=embed) + + @command(name="snakes.sal()", aliases=["snakes.sal"]) + @locked() + async def sal(self, ctx: Context): + """ + Play a game of Snakes and Ladders! + + Written by Momo and kel. + Modified by lemon. + """ + + # check if there is already a game in this channel + if ctx.channel in self.active_sal: + await ctx.send(f"{ctx.author.mention} A game is already in progress in this channel.") + return + + game = sal.SnakeAndLaddersGame(snakes=self, context=ctx) + self.active_sal[ctx.channel] = game + + await game.open_game() + + @command(name="snakes.about()", aliases=["snakes.about"]) + async def snake_about(self, ctx: Context): + """ + A command that shows an embed with information about the event, + it's participants, and its winners. + """ + + contributors = [ + "<@!245270749919576066>", + "<@!396290259907903491>", + "<@!172395097705414656>", + "<@!361708843425726474>", + "<@!300302216663793665>", + "<@!210248051430916096>", + "<@!174588005745557505>", + "<@!87793066227822592>", + "<@!211619754039967744>", + "<@!97347867923976192>", + "<@!136081839474343936>", + "<@!263560579770220554>", + "<@!104749643715387392>", + "<@!303940835005825024>", + ] + + embed = Embed( + title="About the snake cog", + description=( + "The features in this cog were created by members of the community " + "during our first ever [code jam event](https://github.com/discord-python/code-jam-1). \n\n" + "The event saw over 50 participants, who competed to write a discord bot cog with a snake theme over " + "48 hours. The staff then selected the best features from all the best teams, and made modifications " + "to ensure they would all work together before integrating them into the community bot.\n\n" + "It was a tight race, but in the end, <@!104749643715387392> and <@!303940835005825024> " + "walked away as grand champions. Make sure you check out `bot.snakes.sal()`, `bot.snakes.draw()` " + "and `bot.snakes.hatch()` to see what they came up with." + ) + ) + + embed.add_field( + name="Contributors", + value=( + ", ".join(contributors) + ) + ) + + await ctx.channel.send(embed=embed) + + @command(name="snakes.card()", aliases=["snakes.card"]) + async def snake_card(self, ctx: Context, name: Snake = None): + """ + Create an interesting little card from a snake! + + Created by juan and Someone during the first code jam. + """ + + # Get the snake data we need + if not name: + name_obj = await self._get_snake_name() + name = name_obj['scientific'] + content = await self._get_snek(name) + + elif isinstance(name, dict): + content = name + + else: + content = await self._get_snek(name) + + # Make the card + async with ctx.typing(): + + stream = BytesIO() + async with async_timeout.timeout(10): + async with self.bot.http_session.get(content['image_list'][0]) as response: + stream.write(await response.read()) + + stream.seek(0) + + func = partial(self._generate_card, stream, content) + final_buffer = await self.bot.loop.run_in_executor(None, func) + + # Send it! + await ctx.send( + f"A wild {content['name'].title()} appears!", + file=File(final_buffer, filename=content['name'].replace(" ", "") + ".png") + ) + + @command(name="snakes.fact()", aliases=["snakes.fact"]) + async def snake_fact(self, ctx: Context): + """ + Gets a snake-related fact + + Written by Andrew and Prithaj. + Modified by lemon. + """ + + # Get a fact from the API. + response = await self.bot.http_session.get(self.facts_url, headers=self.headers) + question = await response.json() + + # Build and send the embed. + embed = Embed( + title="Snake fact", + color=SNAKE_COLOR, + description=question + ) + await ctx.channel.send(embed=embed) + + @command(name="snakes()", aliases=["snakes"]) + async def snake_help(self, ctx: Context): + """ + This just invokes the help command on this cog. + """ + + log.debug(f"{ctx.author} requested info about the snakes cog") + return await ctx.invoke(self.bot.get_command("help"), "Snakes") + + @command(name="snakes.snakify()", aliases=["snakes.snakify"]) + async def snakify(self, ctx: Context, message: str = None): + """ + How would I talk if I were a snake? + :param ctx: context + :param message: If this is passed, it will snakify the message. + If not, it will snakify a random message from + the users history. + + Written by Momo and kel. + Modified by lemon. + """ + + with ctx.typing(): + embed = Embed() + user = ctx.message.author + + if not message: + + # Get a random message from the users history + messages = [] + async for message in ctx.channel.history(limit=500).filter( + lambda msg: msg.author == ctx.message.author # Message was sent by author. + ): + messages.append(message.content) + + message = self._get_random_long_message(messages) + + # Set the avatar + if user.avatar is not None: + avatar = f"https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}" + else: + avatar = ctx.author.default_avatar_url + + # Build and send the embed + embed.set_author( + name=f"{user.name}#{user.discriminator}", + icon_url=avatar, + ) + embed.description = f"*{self._snakify(message)}*" + + await ctx.channel.send(embed=embed) + + @command(name="snakes.video()", aliases=["snakes.video", "snakes.get_video()", "snakes.get_video"]) + async def video(self, ctx: Context, search: str = None): + """ + Gets a YouTube video about snakes + :param name: Optional, a name of a snake. Used to search for videos with that name + :param ctx: Context object passed from discord.py + + Written by Andrew and Prithaj. + """ + + # Are we searching for anything specific? + if search: + query = search + ' snake' + else: + snake = await self._get_snake_name() + query = snake['name'] + + # Build the URL and make the request + url = f'https://www.googleapis.com/youtube/v3/search' + response = await self.bot.http_session.get( + url, + params={ + "part": "snippet", + "q": urllib.parse.quote(query), + "type": "video", + "key": YOUTUBE_API_KEY + } + ) + response = await response.json() + data = response['items'] + + # Send the user a video + if len(data) > 0: + num = random.randint(0, len(data) - 1) + youtube_base_url = 'https://www.youtube.com/watch?v=' + await ctx.channel.send( + content=f"{youtube_base_url}{data[num]['id']['videoId']}" + ) + else: + log.warning(f"YouTube API error. Full response looks like {response}") + + @command(name="snakes.zen()", aliases=["zen"]) + async def zen(self, ctx: Context): + """ + Gets a random quote from the Zen of Python, + except as if spoken by a snake. + + Written by Prithaj and Andrew. + Modified by lemon. + """ + + embed = Embed( + title="Zzzen of Pythhon", + color=SNAKE_COLOR + ) + + # Get the zen quote and snakify it + zen_quote = random.choice(ZEN.splitlines()) + zen_quote = self._snakify(zen_quote) + + # Embed and send + embed.description = zen_quote + await ctx.channel.send( + embed=embed + ) + # endregion + + # region: Error handlers + @get.error + @snake_card.error + @video.error + async def command_error(self, ctx, error): + + embed = Embed() + embed.colour = Colour.red() + + if isinstance(error, BadArgument): + embed.description = str(error) + embed.title = random.choice(ERROR_REPLIES) + + elif isinstance(error, OSError): + log.error(f"snake_card encountered an OSError: {error} ({error.original})") + embed.description = "Could not generate the snake card! Please try again." + embed.title = random.choice(ERROR_REPLIES) + + else: + log.error(f"Unhandled tag command error: {error} ({error.original})") + return + + await ctx.send(embed=embed) + # endregion + + +def setup(bot): + bot.add_cog(Snakes(bot)) + log.info("Cog loaded: Snakes") diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index c0e10c723..46be1c44a 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -11,7 +11,7 @@ from discord.ext.commands import ( from bot.constants import ( ADMIN_ROLE, BOT_COMMANDS_CHANNEL, DEVTEST_CHANNEL, ERROR_REPLIES, HELPERS_CHANNEL, MODERATOR_ROLE, OWNER_ROLE, - SITE_API_KEY, SITE_API_TAGS_URL, TAG_COOLDOWN + SITE_API_KEY, SITE_API_URL, TAG_COOLDOWN ) from bot.decorators import with_role from bot.pagination import LinePaginator @@ -87,6 +87,7 @@ class Tags: self.bot = bot self.tag_cooldowns = {} self.headers = {"X-API-KEY": SITE_API_KEY} + self.url = f"{SITE_API_URL}/bot/tags" async def get_tag_data(self, tag_name=None) -> dict: """ @@ -103,7 +104,7 @@ class Tags: if tag_name: params["tag_name"] = tag_name - response = await self.bot.http_session.get(SITE_API_TAGS_URL, headers=self.headers, params=params) + response = await self.bot.http_session.get(self.url, headers=self.headers, params=params) tag_data = await response.json() return tag_data @@ -125,7 +126,7 @@ class Tags: 'tag_content': tag_content } - response = await self.bot.http_session.post(SITE_API_TAGS_URL, headers=self.headers, json=params) + response = await self.bot.http_session.post(self.url, headers=self.headers, json=params) tag_data = await response.json() return tag_data @@ -146,7 +147,7 @@ class Tags: if tag_name: params['tag_name'] = tag_name - response = await self.bot.http_session.delete(SITE_API_TAGS_URL, headers=self.headers, json=params) + response = await self.bot.http_session.delete(self.url, headers=self.headers, json=params) tag_data = await response.json() return tag_data diff --git a/bot/constants.py b/bot/constants.py index 651ffe0a8..a11be7506 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -37,18 +37,18 @@ CLICKUP_TEAM = 754996 DEPLOY_URL = os.environ.get("DEPLOY_URL") STATUS_URL = os.environ.get("STATUS_URL") SITE_URL = os.environ.get("SITE_URL", "pythondiscord.local:8080") -SITE_PROTOCOL = 'http' if 'local' in SITE_URL else 'https' +SITE_PROTOCOL = 'http' if DEBUG_MODE else 'https' SITE_API_URL = f"{SITE_PROTOCOL}://api.{SITE_URL}" -SITE_API_USER_URL = f"{SITE_API_URL}/user" -SITE_API_TAGS_URL = f"{SITE_API_URL}/tags" -SITE_API_HIPHOPIFY_URL = f"{SITE_API_URL}/hiphopify" GITHUB_URL_BOT = "https://github.com/discord-python/bot" BOT_AVATAR_URL = "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png" +OMDB_URL = "http://www.omdbapi.com/" # Keys DEPLOY_BOT_KEY = os.environ.get("DEPLOY_BOT_KEY") DEPLOY_SITE_KEY = os.environ.get("DEPLOY_SITE_KEY") SITE_API_KEY = os.environ.get("BOT_API_KEY") +YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY") +OMDB_API_KEY = os.getenv("OMDB_API_KEY") # Bot internals HELP_PREFIX = "bot." @@ -64,6 +64,10 @@ WHITE_CHEVRON = "<:whitechevron:418110396973711363>" PAPERTRAIL_ADDRESS = os.environ.get("PAPERTRAIL_ADDRESS") or None PAPERTRAIL_PORT = int(os.environ.get("PAPERTRAIL_PORT") or 0) +# Paths +BOT_DIR = os.path.dirname(__file__) +PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) + # Bot replies NEGATIVE_REPLIES = [ "Noooooo!!", diff --git a/bot/converters.py b/bot/converters.py new file mode 100644 index 000000000..a629768b7 --- /dev/null +++ b/bot/converters.py @@ -0,0 +1,110 @@ +import random +import socket + +import discord +from aiohttp import AsyncResolver, ClientSession, TCPConnector +from discord.ext.commands import Converter +from fuzzywuzzy import fuzz + +from bot.constants import DEBUG_MODE, SITE_API_KEY, SITE_API_URL +from bot.utils import disambiguate + +NAMES_URL = f"{SITE_API_URL}/bot/snake_names" +SPECIAL_URL = f"{SITE_API_URL}/bot/special_snakes" + + +class Snake(Converter): + snakes = None + special_cases = None + + async def convert(self, ctx, name): + await self.build_list() + name = name.lower() + + if name == 'python': + return 'Python (programming language)' + + def get_potential(iterable, *, threshold=80): + nonlocal name + potential = [] + + for item in iterable: + original, item = item, item.lower() + + if name == item: + return [original] + + a, b = fuzz.ratio(name, item), fuzz.partial_ratio(name, item) + if a >= threshold or b >= threshold: + potential.append(original) + + return potential + + # Handle special cases + if name.lower() in self.special_cases: + return self.special_cases.get(name.lower(), name.lower()) + + names = {snake['name']: snake['scientific'] for snake in self.snakes} + all_names = names.keys() | names.values() + timeout = len(all_names) * (3 / 4) + + embed = discord.Embed(title='Found multiple choices. Please choose the correct one.', colour=0x59982F) + embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar_url) + + name = await disambiguate(ctx, get_potential(all_names), timeout=timeout, embed=embed) + return names.get(name, name) + + @classmethod + async def build_list(cls): + + headers = {"X-API-KEY": SITE_API_KEY} + + # Set up the session + if DEBUG_MODE: + http_session = ClientSession( + connector=TCPConnector( + resolver=AsyncResolver(), + family=socket.AF_INET, + verify_ssl=False, + ) + ) + else: + http_session = ClientSession( + connector=TCPConnector( + resolver=AsyncResolver() + ) + ) + + # Get all the snakes + if cls.snakes is None: + response = await http_session.get( + NAMES_URL, + params={"get_all": "true"}, + headers=headers + ) + cls.snakes = await response.json() + + # Get the special cases + if cls.special_cases is None: + response = await http_session.get( + SPECIAL_URL, + headers=headers + ) + special_cases = await response.json() + cls.special_cases = {snake['name'].lower(): snake for snake in special_cases} + + # Close the session + http_session.close() + + @classmethod + async def random(cls): + """ + This is stupid. We should find a way to + somehow get the global session into a + global context, so I can get it from here. + :return: + """ + + await cls.build_list() + names = [snake['scientific'] for snake in cls.snakes] + return random.choice(names) diff --git a/bot/decorators.py b/bot/decorators.py index b84b2c360..fe974cbd3 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,8 +1,15 @@ import logging +import random +from asyncio import Lock +from functools import wraps +from weakref import WeakValueDictionary +from discord import Colour, Embed from discord.ext import commands from discord.ext.commands import Context +from bot.constants import ERROR_REPLIES + log = logging.getLogger(__name__) @@ -46,3 +53,36 @@ def in_channel(channel_id): f"The result of the in_channel check was {check}.") return check return commands.check(predicate) + + +def locked(): + """ + Allows the user to only run one instance of the decorated command at a time. + Subsequent calls to the command from the same author are + ignored until the command has completed invocation. + + This decorator has to go before (below) the `command` decorator. + """ + + def wrap(func): + func.__locks = WeakValueDictionary() + + @wraps(func) + async def inner(self, ctx, *args, **kwargs): + lock = func.__locks.setdefault(ctx.author.id, Lock()) + if lock.locked(): + embed = Embed() + embed.colour = Colour.red() + + log.debug(f"User tried to invoke a locked command.") + embed.description = ( + "You're already using this command. Please wait until it is done before you use it again." + ) + embed.title = random.choice(ERROR_REPLIES) + await ctx.send(embed=embed) + return + + async with func.__locks.setdefault(ctx.author.id, Lock()): + return await func(self, ctx, *args, **kwargs) + return inner + return wrap diff --git a/bot/resources/snake_cards/backs/card_back1.jpg b/bot/resources/snake_cards/backs/card_back1.jpg Binary files differnew file mode 100644 index 000000000..22959fa73 --- /dev/null +++ b/bot/resources/snake_cards/backs/card_back1.jpg diff --git a/bot/resources/snake_cards/backs/card_back2.jpg b/bot/resources/snake_cards/backs/card_back2.jpg Binary files differnew file mode 100644 index 000000000..d56edc320 --- /dev/null +++ b/bot/resources/snake_cards/backs/card_back2.jpg diff --git a/bot/resources/snake_cards/card_bottom.png b/bot/resources/snake_cards/card_bottom.png Binary files differnew file mode 100644 index 000000000..8b2b91c5c --- /dev/null +++ b/bot/resources/snake_cards/card_bottom.png diff --git a/bot/resources/snake_cards/card_frame.png b/bot/resources/snake_cards/card_frame.png Binary files differnew file mode 100644 index 000000000..149a0a5f6 --- /dev/null +++ b/bot/resources/snake_cards/card_frame.png diff --git a/bot/resources/snake_cards/card_top.png b/bot/resources/snake_cards/card_top.png Binary files differnew file mode 100644 index 000000000..e329c873a --- /dev/null +++ b/bot/resources/snake_cards/card_top.png diff --git a/bot/resources/snake_cards/expressway.ttf b/bot/resources/snake_cards/expressway.ttf Binary files differnew file mode 100644 index 000000000..39e157947 --- /dev/null +++ b/bot/resources/snake_cards/expressway.ttf diff --git a/bot/resources/snakes_and_ladders/banner.jpg b/bot/resources/snakes_and_ladders/banner.jpg Binary files differnew file mode 100644 index 000000000..69eaaf129 --- /dev/null +++ b/bot/resources/snakes_and_ladders/banner.jpg diff --git a/bot/resources/snakes_and_ladders/board.jpg b/bot/resources/snakes_and_ladders/board.jpg Binary files differnew file mode 100644 index 000000000..20032e391 --- /dev/null +++ b/bot/resources/snakes_and_ladders/board.jpg diff --git a/bot/utils.py b/bot/utils.py deleted file mode 100644 index aaea1feeb..000000000 --- a/bot/utils.py +++ /dev/null @@ -1,55 +0,0 @@ -class CaseInsensitiveDict(dict): - """ - We found this class on StackOverflow. Thanks to m000 for writing it! - - https://stackoverflow.com/a/32888599/4022104 - """ - - @classmethod - def _k(cls, key): - return key.lower() if isinstance(key, str) else key - - def __init__(self, *args, **kwargs): - super(CaseInsensitiveDict, self).__init__(*args, **kwargs) - self._convert_keys() - - def __getitem__(self, key): - return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) - - def __setitem__(self, key, value): - super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) - - def __delitem__(self, key): - return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) - - def __contains__(self, key): - return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) - - def pop(self, key, *args, **kwargs): - return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs) - - def get(self, key, *args, **kwargs): - return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs) - - def setdefault(self, key, *args, **kwargs): - return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs) - - def update(self, E=None, **F): - super(CaseInsensitiveDict, self).update(self.__class__(E)) - super(CaseInsensitiveDict, self).update(self.__class__(**F)) - - def _convert_keys(self): - for k in list(self.keys()): - v = super(CaseInsensitiveDict, self).pop(k) - self.__setitem__(k, v) - - -def chunks(iterable, size): - """ - Generator that allows you to iterate over any indexable collection in `size`-length chunks - - Found: https://stackoverflow.com/a/312464/4022104 - """ - - for i in range(0, len(iterable), size): - yield iterable[i:i + size] diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 000000000..1a902b68c --- /dev/null +++ b/bot/utils/__init__.py @@ -0,0 +1,137 @@ +import asyncio +from typing import List + +import discord +from discord.ext.commands import BadArgument, Context + +from bot.pagination import LinePaginator + + +async def disambiguate( + ctx: Context, entries: List[str], *, timeout: float = 30, + per_page: int = 20, empty: bool = False, embed: discord.Embed = None +): + """ + Has the user choose between multiple entries in case one could not be chosen automatically. + + This will raise a BadArgument if entries is empty, if the disambiguation event times out, + or if the user makes an invalid choice. + + :param ctx: Context object from discord.py + :param entries: List of items for user to choose from + :param timeout: Number of seconds to wait before canceling disambiguation + :param per_page: Entries per embed page + :param empty: Whether the paginator should have an extra line between items + :param embed: The embed that the paginator will use. + :return: Users choice for correct entry. + """ + + if len(entries) == 0: + raise BadArgument('No matches found.') + + if len(entries) == 1: + return entries[0] + + choices = (f'{index}: {entry}' for index, entry in enumerate(entries, start=1)) + + def check(message): + return (message.content.isdigit() and + message.author == ctx.author and + message.channel == ctx.channel) + + try: + if embed is None: + embed = discord.Embed() + + coro1 = ctx.bot.wait_for('message', check=check, timeout=timeout) + coro2 = LinePaginator.paginate(choices, ctx, embed=embed, max_lines=per_page, + empty=empty, max_size=6000, timeout=9000) + + # wait_for timeout will go to except instead of the wait_for thing as I expected + futures = [asyncio.ensure_future(coro1), asyncio.ensure_future(coro2)] + done, pending = await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED, loop=ctx.bot.loop) + + # :yert: + result = list(done)[0].result() + + # Pagination was canceled - result is None + if result is None: + for coro in pending: + coro.cancel() + raise BadArgument('Canceled.') + + # Pagination was not initiated, only one page + if result.author == ctx.bot.user: + # Continue the wait_for + result = await list(pending)[0] + + # Love that duplicate code + for coro in pending: + coro.cancel() + except asyncio.TimeoutError: + raise BadArgument('Timed out.') + + # Guaranteed to not error because of isdigit() in check + index = int(result.content) + + try: + return entries[index - 1] + except IndexError: + raise BadArgument('Invalid choice.') + + +class CaseInsensitiveDict(dict): + """ + We found this class on StackOverflow. Thanks to m000 for writing it! + + https://stackoverflow.com/a/32888599/4022104 + """ + + @classmethod + def _k(cls, key): + return key.lower() if isinstance(key, str) else key + + def __init__(self, *args, **kwargs): + super(CaseInsensitiveDict, self).__init__(*args, **kwargs) + self._convert_keys() + + def __getitem__(self, key): + return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) + + def __setitem__(self, key, value): + super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) + + def __delitem__(self, key): + return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) + + def __contains__(self, key): + return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) + + def pop(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs) + + def get(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs) + + def setdefault(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs) + + def update(self, E=None, **F): + super(CaseInsensitiveDict, self).update(self.__class__(E)) + super(CaseInsensitiveDict, self).update(self.__class__(**F)) + + def _convert_keys(self): + for k in list(self.keys()): + v = super(CaseInsensitiveDict, self).pop(k) + self.__setitem__(k, v) + + +def chunks(iterable, size): + """ + Generator that allows you to iterate over any indexable collection in `size`-length chunks + + Found: https://stackoverflow.com/a/312464/4022104 + """ + + for i in range(0, len(iterable), size): + yield iterable[i:i + size] diff --git a/bot/utils/snakes/__init__.py b/bot/utils/snakes/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/bot/utils/snakes/__init__.py diff --git a/bot/utils/snakes/hatching.py b/bot/utils/snakes/hatching.py new file mode 100644 index 000000000..c37ac0f50 --- /dev/null +++ b/bot/utils/snakes/hatching.py @@ -0,0 +1,44 @@ +h1 = '''``` + ---- + ------ + /--------\\ + |--------| + |--------| + \------/ + ----```''' + +h2 = '''``` + ---- + ------ + /---\\-/--\\ + |-----\\--| + |--------| + \------/ + ----```''' + +h3 = '''``` + ---- + ------ + /---\\-/--\\ + |-----\\--| + |-----/--| + \----\\-/ + ----```''' + +h4 = '''``` + ----- + ----- \\ + /--| /---\\ + |--\\ -\\---| + |--\\--/-- / + \------- / + ------```''' + +stages = [h1, h2, h3, h4] +snakes = { + "Baby Python": "https://i.imgur.com/SYOcmSa.png", + "Baby Rattle Snake": "https://i.imgur.com/i5jYA8f.png", + "Baby Dragon Snake": "https://i.imgur.com/SuMKM4m.png", + "Baby Garden Snake": "https://i.imgur.com/5vYx3ah.png", + "Baby Cobra": "https://i.imgur.com/jk14ryt.png" +} diff --git a/bot/utils/snakes/perlin.py b/bot/utils/snakes/perlin.py new file mode 100644 index 000000000..0401787ef --- /dev/null +++ b/bot/utils/snakes/perlin.py @@ -0,0 +1,158 @@ +""" +Perlin noise implementation. +Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 +Licensed under ISC +""" + +import math +import random +from itertools import product + + +def smoothstep(t): + """Smooth curve with a zero derivative at 0 and 1, making it useful for + interpolating. + """ + return t * t * (3. - 2. * t) + + +def lerp(t, a, b): + """Linear interpolation between a and b, given a fraction t.""" + return a + t * (b - a) + + +class PerlinNoiseFactory(object): + """Callable that produces Perlin noise for an arbitrary point in an + arbitrary number of dimensions. The underlying grid is aligned with the + integers. + There is no limit to the coordinates used; new gradients are generated on + the fly as necessary. + """ + + def __init__(self, dimension, octaves=1, tile=(), unbias=False): + """Create a new Perlin noise factory in the given number of dimensions, + which should be an integer and at least 1. + More octaves create a foggier and more-detailed noise pattern. More + than 4 octaves is rather excessive. + ``tile`` can be used to make a seamlessly tiling pattern. For example: + pnf = PerlinNoiseFactory(2, tile=(0, 3)) + This will produce noise that tiles every 3 units vertically, but never + tiles horizontally. + If ``unbias`` is true, the smoothstep function will be applied to the + output before returning it, to counteract some of Perlin noise's + significant bias towards the center of its output range. + """ + self.dimension = dimension + self.octaves = octaves + self.tile = tile + (0,) * dimension + self.unbias = unbias + + # For n dimensions, the range of Perlin noise is ±sqrt(n)/2; multiply + # by this to scale to ±1 + self.scale_factor = 2 * dimension ** -0.5 + + self.gradient = {} + + def _generate_gradient(self): + # Generate a random unit vector at each grid point -- this is the + # "gradient" vector, in that the grid tile slopes towards it + + # 1 dimension is special, since the only unit vector is trivial; + # instead, use a slope between -1 and 1 + if self.dimension == 1: + return (random.uniform(-1, 1),) + + # Generate a random point on the surface of the unit n-hypersphere; + # this is the same as a random unit vector in n dimensions. Thanks + # to: http://mathworld.wolfram.com/SpherePointPicking.html + # Pick n normal random variables with stddev 1 + random_point = [random.gauss(0, 1) for _ in range(self.dimension)] + # Then scale the result to a unit vector + scale = sum(n * n for n in random_point) ** -0.5 + return tuple(coord * scale for coord in random_point) + + def get_plain_noise(self, *point): + """Get plain noise for a single point, without taking into account + either octaves or tiling. + """ + if len(point) != self.dimension: + raise ValueError("Expected {0} values, got {1}".format( + self.dimension, len(point))) + + # Build a list of the (min, max) bounds in each dimension + grid_coords = [] + for coord in point: + min_coord = math.floor(coord) + max_coord = min_coord + 1 + grid_coords.append((min_coord, max_coord)) + + # Compute the dot product of each gradient vector and the point's + # distance from the corresponding grid point. This gives you each + # gradient's "influence" on the chosen point. + dots = [] + for grid_point in product(*grid_coords): + if grid_point not in self.gradient: + self.gradient[grid_point] = self._generate_gradient() + gradient = self.gradient[grid_point] + + dot = 0 + for i in range(self.dimension): + dot += gradient[i] * (point[i] - grid_point[i]) + dots.append(dot) + + # Interpolate all those dot products together. The interpolation is + # done with smoothstep to smooth out the slope as you pass from one + # grid cell into the next. + # Due to the way product() works, dot products are ordered such that + # the last dimension alternates: (..., min), (..., max), etc. So we + # can interpolate adjacent pairs to "collapse" that last dimension. Then + # the results will alternate in their second-to-last dimension, and so + # forth, until we only have a single value left. + dim = self.dimension + while len(dots) > 1: + dim -= 1 + s = smoothstep(point[dim] - grid_coords[dim][0]) + + next_dots = [] + while dots: + next_dots.append(lerp(s, dots.pop(0), dots.pop(0))) + + dots = next_dots + + return dots[0] * self.scale_factor + + def __call__(self, *point): + """Get the value of this Perlin noise function at the given point. The + number of values given should match the number of dimensions. + """ + ret = 0 + for o in range(self.octaves): + o2 = 1 << o + new_point = [] + for i, coord in enumerate(point): + coord *= o2 + if self.tile[i]: + coord %= self.tile[i] * o2 + new_point.append(coord) + ret += self.get_plain_noise(*new_point) / o2 + + # Need to scale n back down since adding all those extra octaves has + # probably expanded it beyond ±1 + # 1 octave: ±1 + # 2 octaves: ±1½ + # 3 octaves: ±1¾ + ret /= 2 - 2 ** (1 - self.octaves) + + if self.unbias: + # The output of the plain Perlin noise algorithm has a fairly + # strong bias towards the center due to the central limit theorem + # -- in fact the top and bottom 1/8 virtually never happen. That's + # a quarter of our entire output range! If only we had a function + # in [0..1] that could introduce a bias towards the endpoints... + r = (ret + 1) / 2 + # Doing it this many times is a completely made-up heuristic. + for _ in range(int(self.octaves / 2 + 0.5)): + r = smoothstep(r) + ret = r * 2 - 1 + + return ret diff --git a/bot/utils/snakes/perlinsneks.py b/bot/utils/snakes/perlinsneks.py new file mode 100644 index 000000000..662281775 --- /dev/null +++ b/bot/utils/snakes/perlinsneks.py @@ -0,0 +1,111 @@ +# perlin sneks! +import io +import math +import random +from typing import Tuple + +from PIL.ImageDraw import Image, ImageDraw + +from bot.utils.snakes import perlin + +DEFAULT_SNAKE_COLOR: int = 0x15c7ea +DEFAULT_BACKGROUND_COLOR: int = 0 +DEFAULT_IMAGE_DIMENSIONS: Tuple[int] = (200, 200) +DEFAULT_SNAKE_LENGTH: int = 22 +DEFAULT_SNAKE_WIDTH: int = 8 +DEFAULT_SEGMENT_LENGTH_RANGE: Tuple[int] = (7, 10) +DEFAULT_IMAGE_MARGINS: Tuple[int] = (50, 50) +DEFAULT_TEXT: str = "snek\nit\nup" +DEFAULT_TEXT_POSITION: Tuple[int] = ( + 10, + 10 +) +DEFAULT_TEXT_COLOR: int = 0xf2ea15 + +X = 0 +Y = 1 +ANGLE_RANGE = math.pi * 2 + + +def create_snek_frame( + perlin_factory: perlin.PerlinNoiseFactory, perlin_lookup_vertical_shift: float = 0, + image_dimensions: Tuple[int] = DEFAULT_IMAGE_DIMENSIONS, image_margins: Tuple[int] = DEFAULT_IMAGE_MARGINS, + snake_length: int = DEFAULT_SNAKE_LENGTH, + snake_color: int = DEFAULT_SNAKE_COLOR, bg_color: int = DEFAULT_BACKGROUND_COLOR, + segment_length_range: Tuple[int] = DEFAULT_SEGMENT_LENGTH_RANGE, snake_width: int = DEFAULT_SNAKE_WIDTH, + text: str = DEFAULT_TEXT, text_position: Tuple[int] = DEFAULT_TEXT_POSITION, + text_color: Tuple[int] = DEFAULT_TEXT_COLOR +) -> Image: + """ + Creates a single random snek frame using Perlin noise. + :param perlin_factory: the perlin noise factory used. Required. + :param perlin_lookup_vertical_shift: the Perlin noise shift in the Y-dimension for this frame + :param image_dimensions: the size of the output image. + :param image_margins: the margins to respect inside of the image. + :param snake_length: the length of the snake, in segments. + :param snake_color: the color of the snake. + :param bg_color: the background color. + :param segment_length_range: the range of the segment length. Values will be generated inside this range, including + the bounds. + :param snake_width: the width of the snek, in pixels. + :param text: the text to display with the snek. Set to None for no text. + :param text_position: the position of the text. + :param text_color: the color of the text. + :return: a PIL image, representing a single frame. + """ + start_x = random.randint(image_margins[X], image_dimensions[X] - image_margins[X]) + start_y = random.randint(image_margins[Y], image_dimensions[Y] - image_margins[Y]) + points = [(start_x, start_y)] + + for index in range(0, snake_length): + angle = perlin_factory.get_plain_noise( + ((1 / (snake_length + 1)) * (index + 1)) + perlin_lookup_vertical_shift + ) * ANGLE_RANGE + current_point = points[index] + segment_length = random.randint(segment_length_range[0], segment_length_range[1]) + points.append(( + current_point[X] + segment_length * math.cos(angle), + current_point[Y] + segment_length * math.sin(angle) + )) + + # normalize bounds + min_dimensions = [start_x, start_y] + max_dimensions = [start_x, start_y] + for point in points: + min_dimensions[X] = min(point[X], min_dimensions[X]) + min_dimensions[Y] = min(point[Y], min_dimensions[Y]) + max_dimensions[X] = max(point[X], max_dimensions[X]) + max_dimensions[Y] = max(point[Y], max_dimensions[Y]) + + # shift towards middle + dimension_range = (max_dimensions[X] - min_dimensions[X], max_dimensions[Y] - min_dimensions[Y]) + shift = ( + image_dimensions[X] / 2 - (dimension_range[X] / 2 + min_dimensions[X]), + image_dimensions[Y] / 2 - (dimension_range[Y] / 2 + min_dimensions[Y]) + ) + + image = Image.new(mode='RGB', size=image_dimensions, color=bg_color) + draw = ImageDraw(image) + for index in range(1, len(points)): + point = points[index] + previous = points[index - 1] + draw.line( + ( + shift[X] + previous[X], + shift[Y] + previous[Y], + shift[X] + point[X], + shift[Y] + point[Y] + ), + width=snake_width, + fill=snake_color + ) + if text is not None: + draw.multiline_text(text_position, text, fill=text_color) + del draw + return image + + +def frame_to_png_bytes(image: Image): + stream = io.BytesIO() + image.save(stream, format='PNG') + return stream.getvalue() diff --git a/bot/utils/snakes/sal.py b/bot/utils/snakes/sal.py new file mode 100644 index 000000000..8530d8a0f --- /dev/null +++ b/bot/utils/snakes/sal.py @@ -0,0 +1,365 @@ +import asyncio +import io +import logging +import math +import os +import random + +import aiohttp +from discord import File, Member, Reaction +from discord.ext.commands import Context +from PIL import Image + +from bot.utils.snakes.sal_board import ( + BOARD, BOARD_MARGIN, BOARD_PLAYER_SIZE, + BOARD_TILE_SIZE, MAX_PLAYERS, PLAYER_ICON_IMAGE_SIZE +) + +log = logging.getLogger(__name__) + +# Emoji constants +START_EMOJI = "\u2611" # :ballot_box_with_check: - Start the game +CANCEL_EMOJI = "\u274C" # :x: - Cancel or leave the game +ROLL_EMOJI = "\U0001F3B2" # :game_die: - Roll the die! +JOIN_EMOJI = "\U0001F64B" # :raising_hand: - Join the game. + +STARTUP_SCREEN_EMOJI = [ + JOIN_EMOJI, + START_EMOJI, + CANCEL_EMOJI +] + +GAME_SCREEN_EMOJI = [ + ROLL_EMOJI, + CANCEL_EMOJI +] + + +class SnakeAndLaddersGame: + def __init__(self, snakes, context: Context): + self.snakes = snakes + self.ctx = context + self.channel = self.ctx.channel + self.state = 'booting' + self.started = False + self.author = self.ctx.author + self.players = [] + self.player_tiles = {} + self.round_has_rolled = {} + self.avatar_images = {} + self.board = None + self.positions = None + self.rolls = [] + + async def open_game(self): + """ + Create a new Snakes and Ladders game. + + Listen for reactions until players have joined, + and the game has been started. + """ + + def startup_event_check(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + return ( + all(( + reaction_.message.id == startup.id, # Reaction is on startup message + reaction_.emoji in STARTUP_SCREEN_EMOJI, # Reaction is one of the startup emotes + user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot + )) + ) + + # Check to see if the bot can remove reactions + if not self.channel.permissions_for(self.ctx.guild.me).manage_messages: + log.warning( + "Unable to start Snakes and Ladders - " + f"Missing manage_messages permissions in {self.channel}" + ) + return + + await self._add_player(self.author) + await self.channel.send( + "**Snakes and Ladders**: A new game is about to start!", + file=File( + os.path.join("bot", "resources", "snakes_and_ladders", "banner.jpg"), + filename='Snakes and Ladders.jpg' + ) + ) + startup = await self.channel.send( + f"Press {JOIN_EMOJI} to participate, and press " + f"{START_EMOJI} to start the game" + ) + for emoji in STARTUP_SCREEN_EMOJI: + await startup.add_reaction(emoji) + + self.state = 'waiting' + + while not self.started: + try: + reaction, user = await self.ctx.bot.wait_for( + "reaction_add", + timeout=300, + check=startup_event_check + ) + if reaction.emoji == JOIN_EMOJI: + await self.player_join(user) + elif reaction.emoji == CANCEL_EMOJI: + if self.ctx.author == user: + await self.cancel_game(user) + return + else: + await self.player_leave(user) + elif reaction.emoji == START_EMOJI: + if self.ctx.author == user: + self.started = True + await self.start_game(user) + await startup.delete() + break + + await startup.remove_reaction(reaction.emoji, user) + + except asyncio.TimeoutError: + log.debug("Snakes and Ladders timed out waiting for a reaction") + self.cancel_game(self.author) + return # We're done, no reactions for the last 5 minutes + + async def _add_player(self, user: Member): + self.players.append(user) + self.player_tiles[user.id] = 1 + avatar_url = user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE) + async with aiohttp.ClientSession() as session: + async with session.get(avatar_url) as res: + avatar_bytes = await res.read() + im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) + self.avatar_images[user.id] = im + + async def player_join(self, user: Member): + for p in self.players: + if user == p: + await self.channel.send(user.mention + " You are already in the game.", delete_after=10) + return + if self.state != 'waiting': + await self.channel.send(user.mention + " You cannot join at this time.", delete_after=10) + return + if len(self.players) is MAX_PLAYERS: + await self.channel.send(user.mention + " The game is full!", delete_after=10) + return + + await self._add_player(user) + + await self.channel.send( + f"**Snakes and Ladders**: {user.mention} has joined the game.\n" + f"There are now {str(len(self.players))} players in the game.", + delete_after=10 + ) + + async def player_leave(self, user: Member): + if user == self.author: + await self.channel.send( + user.mention + " You are the author, and cannot leave the game. Execute " + "`sal cancel` to cancel the game.", + delete_after=10 + ) + return + for p in self.players: + if user == p: + self.players.remove(p) + self.player_tiles.pop(p.id, None) + self.round_has_rolled.pop(p.id, None) + await self.channel.send( + "**Snakes and Ladders**: " + user.mention + " has left the game.", + delete_after=10 + ) + + if self.state != 'waiting' and len(self.players) == 1: + await self.channel.send("**Snakes and Ladders**: The game has been surrendered!") + self._destruct() + return + await self.channel.send(user.mention + " You are not in the match.", delete_after=10) + + async def cancel_game(self, user: Member): + if not user == self.author: + await self.channel.send(user.mention + " Only the author of the game can cancel it.", delete_after=10) + return + await self.channel.send("**Snakes and Ladders**: Game has been canceled.") + self._destruct() + + async def start_game(self, user: Member): + if not user == self.author: + await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10) + return + if len(self.players) < 1: + await self.channel.send( + user.mention + " A minimum of 2 players is required to start the game.", + delete_after=10 + ) + return + if not self.state == 'waiting': + await self.channel.send(user.mention + " The game cannot be started at this time.", delete_after=10) + return + self.state = 'starting' + player_list = ', '.join(user.mention for user in self.players) + await self.channel.send("**Snakes and Ladders**: The game is starting!\nPlayers: " + player_list) + await self.start_round() + + async def start_round(self): + + def game_event_check(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + return ( + all(( + reaction_.message.id == self.positions.id, # Reaction is on positions message + reaction_.emoji in GAME_SCREEN_EMOJI, # Reaction is one of the game emotes + user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot + )) + ) + + self.state = 'roll' + for user in self.players: + self.round_has_rolled[user.id] = False + board_img = Image.open(os.path.join("bot", "resources", "snakes_and_ladders", "board.jpg")) + player_row_size = math.ceil(MAX_PLAYERS / 2) + + for i, player in enumerate(self.players): + tile = self.player_tiles[player.id] + tile_coordinates = self._board_coordinate_from_index(tile) + x_offset = BOARD_MARGIN[0] + tile_coordinates[0] * BOARD_TILE_SIZE + y_offset = \ + BOARD_MARGIN[1] + ( + (10 * BOARD_TILE_SIZE) - (9 - tile_coordinates[1]) * BOARD_TILE_SIZE - BOARD_PLAYER_SIZE) + x_offset += BOARD_PLAYER_SIZE * (i % player_row_size) + y_offset -= BOARD_PLAYER_SIZE * math.floor(i / player_row_size) + board_img.paste(self.avatar_images[player.id], + box=(x_offset, y_offset)) + stream = io.BytesIO() + board_img.save(stream, format='JPEG') + board_file = File(stream.getvalue(), filename='Board.jpg') + player_list = '\n'.join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players) + + # Store and send new messages + temp_board = await self.channel.send( + "**Snakes and Ladders**: A new round has started! Current board:", + file=board_file + ) + temp_positions = await self.channel.send( + f"**Current positions**:\n{player_list}\n\nUse {ROLL_EMOJI} to roll the dice!" + ) + + # Delete the previous messages + if self.board and self.positions: + await self.board.delete() + await self.positions.delete() + + # remove the roll messages + for roll in self.rolls: + await roll.delete() + self.rolls = [] + + # Save new messages + self.board = temp_board + self.positions = temp_positions + + # Wait for rolls + for emoji in GAME_SCREEN_EMOJI: + await self.positions.add_reaction(emoji) + + while True: + try: + reaction, user = await self.ctx.bot.wait_for( + "reaction_add", + timeout=300, + check=game_event_check + ) + + if reaction.emoji == ROLL_EMOJI: + await self.player_roll(user) + elif reaction.emoji == CANCEL_EMOJI: + if self.ctx.author == user: + await self.cancel_game(user) + return + else: + await self.player_leave(user) + + await self.positions.remove_reaction(reaction.emoji, user) + + if self._check_all_rolled(): + break + + except asyncio.TimeoutError: + log.debug("Snakes and Ladders timed out waiting for a reaction") + await self.cancel_game(self.author) + return # We're done, no reactions for the last 5 minutes + + # Round completed + await self._complete_round() + + async def player_roll(self, user: Member): + if user.id not in self.player_tiles: + await self.channel.send(user.mention + " You are not in the match.", delete_after=10) + return + if self.state != 'roll': + await self.channel.send(user.mention + " You may not roll at this time.", delete_after=10) + return + if self.round_has_rolled[user.id]: + return + roll = random.randint(1, 6) + self.rolls.append(await self.channel.send(f"{user.mention} rolled a **{roll}**!")) + next_tile = self.player_tiles[user.id] + roll + + # apply snakes and ladders + if next_tile in BOARD: + target = BOARD[next_tile] + if target < next_tile: + await self.channel.send( + f"{user.mention} slips on a snake and falls back to **{target}**", + delete_after=15 + ) + else: + await self.channel.send( + f"{user.mention} climbs a ladder to **{target}**", + delete_after=15 + ) + next_tile = target + + self.player_tiles[user.id] = min(100, next_tile) + self.round_has_rolled[user.id] = True + + async def _complete_round(self): + + self.state = 'post_round' + + # check for winner + winner = self._check_winner() + if winner is None: + # there is no winner, start the next round + await self.start_round() + return + + # announce winner and exit + await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:") + self._destruct() + + def _check_winner(self) -> Member: + if self.state != 'post_round': + return None + return next((player for player in self.players if self.player_tiles[player.id] == 100), + None) + + def _check_all_rolled(self): + return all(rolled for rolled in self.round_has_rolled.values()) + + def _destruct(self): + del self.snakes.active_sal[self.channel] + + def _board_coordinate_from_index(self, index: int): + # converts the tile number to the x/y coordinates for graphical purposes + y_level = 9 - math.floor((index - 1) / 10) + is_reversed = math.floor((index - 1) / 10) % 2 != 0 + x_level = (index - 1) % 10 + if is_reversed: + x_level = 9 - x_level + return x_level, y_level diff --git a/bot/utils/snakes/sal_board.py b/bot/utils/snakes/sal_board.py new file mode 100644 index 000000000..1b8eab451 --- /dev/null +++ b/bot/utils/snakes/sal_board.py @@ -0,0 +1,33 @@ +BOARD_TILE_SIZE = 56 # the size of each board tile +BOARD_PLAYER_SIZE = 20 # the size of each player icon +BOARD_MARGIN = (10, 0) # margins, in pixels (for player icons) +PLAYER_ICON_IMAGE_SIZE = 32 # the size of the image to download, should a power of 2 and higher than BOARD_PLAYER_SIZE +MAX_PLAYERS = 4 # depends on the board size/quality, 4 is for the default board + +# board definition (from, to) +BOARD = { + # ladders + 2: 38, + 7: 14, + 8: 31, + 15: 26, + 21: 42, + 28: 84, + 36: 44, + 51: 67, + 71: 91, + 78: 98, + 87: 94, + + # snakes + 99: 80, + 95: 75, + 92: 88, + 89: 68, + 74: 53, + 64: 60, + 62: 19, + 49: 11, + 46: 25, + 16: 6 +} |