diff options
Diffstat (limited to 'bot/seasons/evergreen')
| -rw-r--r-- | bot/seasons/evergreen/snakes/__init__.py | 10 | ||||
| -rw-r--r-- | bot/seasons/evergreen/snakes/converter.py | 80 | ||||
| -rw-r--r-- | bot/seasons/evergreen/snakes/snakes_cog.py | 1194 | ||||
| -rw-r--r-- | bot/seasons/evergreen/snakes/utils.py | 700 | 
4 files changed, 1984 insertions, 0 deletions
diff --git a/bot/seasons/evergreen/snakes/__init__.py b/bot/seasons/evergreen/snakes/__init__.py new file mode 100644 index 00000000..367aea4d --- /dev/null +++ b/bot/seasons/evergreen/snakes/__init__.py @@ -0,0 +1,10 @@ +import logging + +from bot.seasons.evergreen.snakes.snakes_cog import Snakes + +log = logging.getLogger(__name__) + + +def setup(bot): +    bot.add_cog(Snakes(bot)) +    log.info("Cog loaded: Snakes") diff --git a/bot/seasons/evergreen/snakes/converter.py b/bot/seasons/evergreen/snakes/converter.py new file mode 100644 index 00000000..c091d9c1 --- /dev/null +++ b/bot/seasons/evergreen/snakes/converter.py @@ -0,0 +1,80 @@ +import json +import logging +import random + +import discord +from discord.ext.commands import Converter +from fuzzywuzzy import fuzz + +from bot.seasons.evergreen.snakes.utils import SNAKE_RESOURCES +from bot.utils import disambiguate + +log = logging.getLogger(__name__) + + +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): +        # Get all the snakes +        if cls.snakes is None: +            with (SNAKE_RESOURCES / "snake_names.json").open() as snakefile: +                cls.snakes = json.load(snakefile) + +        # Get the special cases +        if cls.special_cases is None: +            with (SNAKE_RESOURCES / "special_snakes.json").open() as snakefile: +                special_cases = json.load(snakefile) +            cls.special_cases = {snake['name'].lower(): snake for snake in special_cases} + +    @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/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py new file mode 100644 index 00000000..57eb7a52 --- /dev/null +++ b/bot/seasons/evergreen/snakes/snakes_cog.py @@ -0,0 +1,1194 @@ +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 PIL import Image, ImageDraw, ImageFont +from discord import Colour, Embed, File, Member, Message, Reaction +from discord.ext.commands import BadArgument, Bot, Context, bot_has_permissions, group + +from bot.constants import ERROR_REPLIES, Tokens +from bot.decorators import locked +from bot.seasons.evergreen.snakes import utils +from bot.seasons.evergreen.snakes.converter import Snake + +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/snakes/snake_cards/card_top.png"), +    "frame": Image.open("bot/resources/snakes/snake_cards/card_frame.png"), +    "bottom": Image.open("bot/resources/snakes/snake_cards/card_bottom.png"), +    "backs": [ +        Image.open(f"bot/resources/snakes/snake_cards/backs/{file}") +        for file in os.listdir("bot/resources/snakes/snake_cards/backs") +    ], +    "font": ImageFont.truetype("bot/resources/snakes/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://gitlab_bot_repo.com/discord-python/code-jams/code-jam-1 +    """ + +    wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL) +    valid_image_extensions = ('gif', 'png', 'jpeg', 'jpg', 'webp') + +    def __init__(self, bot: Bot): +        self.active_sal = {} +        self.bot = bot +        self.snake_names = utils.get_resource("snake_names") +        self.snake_idioms = utils.get_resource("snake_idioms") +        self.snake_quizzes = utils.get_resource("snake_quiz") +        self.snake_facts = utils.get_resource("snake_facts") + +    # 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 and retries > 0: +            return self._get_random_long_message( +                messages, +                retries=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. +        """ +        return random.choice(self.snake_names) + +    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 +    @group(name='snakes', aliases=('snake',), invoke_without_command=True) +    async def snakes_group(self, ctx: Context): +        """Commands from our first code jam.""" + +        await ctx.invoke(self.bot.get_command("help"), "snake") + +    @bot_has_permissions(manage_messages=True) +    @snakes_group.command(name='antidote') +    @locked() +    async def antidote_command(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 is on this message +                    reaction_.message.id == board_id.id, +                    # Reaction is one of the pagination emotes +                    reaction_.emoji in ANTIDOTE_EMOJI, +                    # Reaction was not made by the Bot +                    user_.id != self.bot.user.id, +                    # Reaction was made by author +                    user_.id == ctx.author.id +                )) +            ) + +        # 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() + +    @snakes_group.command(name='draw') +    async def draw_command(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), +            ) + +            # Build and send the snek +            text = random.choice(self.snake_idioms)["idiom"] +            factory = utils.PerlinNoiseFactory(dimension=1, octaves=2) +            image_frame = utils.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 = utils.frame_to_png_bytes(image_frame) +            file = File(png_bytes, filename='snek.png') +            await ctx.send(file=file) + +    @snakes_group.command(name='get') +    @bot_has_permissions(manage_messages=True) +    @locked() +    async def get_command(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) + +    @snakes_group.command(name='guess', aliases=('identify',)) +    @locked() +    async def guess_command(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) + +    @snakes_group.command(name='hatch') +    async def hatch_command(self, ctx: Context): +        """ +        Hatches your personal snake + +        Written by Momo and kel. +        """ +        # Pick a random snake to hatch. +        snake_name = random.choice(list(utils.snakes.keys())) +        snake_image = utils.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 utils.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) + +    @snakes_group.command(name='movie') +    async def movie_command(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": Tokens.omdb +            } +        ) +        data = await response.json() +        movie = random.choice(data["Search"])["imdbID"] + +        response = await self.bot.http_session.get( +            url, +            params={ +                "i": movie, +                "apikey": Tokens.omdb +            } +        ) +        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 +        ) + +    @snakes_group.command(name='quiz') +    @locked() +    async def quiz_command(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. +        question = random.choice(self.snake_quizzes) +        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) + +    @snakes_group.command(name='name', aliases=('name_gen',)) +    async def name_command(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) + +    @snakes_group.command(name='sal') +    @locked() +    async def sal_command(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 = utils.SnakeAndLaddersGame(snakes=self, context=ctx) +        self.active_sal[ctx.channel] = game + +        await game.open_game() + +    @snakes_group.command(name='about') +    async def about_command(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://gitlab.com/discord-python/code-jams/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 `!snakes sal`, `!snakes draw` " +                "and `!snakes hatch` to see what they came up with." +            ) +        ) + +        embed.add_field( +            name="Contributors", +            value=( +                ", ".join(contributors) +            ) +        ) + +        await ctx.channel.send(embed=embed) + +    @snakes_group.command(name='card') +    async def card_command(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") +        ) + +    @snakes_group.command(name='fact') +    async def fact_command(self, ctx: Context): +        """ +        Gets a snake-related fact + +        Written by Andrew and Prithaj. +        Modified by lemon. +        """ +        question = random.choice(self.snake_facts)["fact"] +        embed = Embed( +            title="Snake fact", +            color=SNAKE_COLOR, +            description=question +        ) +        await ctx.channel.send(embed=embed) + +    @snakes_group.command(name='help') +    async def help_command(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") + +    @snakes_group.command(name='snakify') +    async def snakify_command(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) + +    @snakes_group.command(name='video', aliases=('get_video',)) +    async def video_command(self, ctx: Context, *, search: str = None): +        """ +        Gets a YouTube video about snakes + +        :param ctx: Context object passed from discord.py +        :param search: Optional, a name of a snake. Used to search for videos with that name + +        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": Tokens.youtube +            } +        ) +        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}") + +    @snakes_group.command(name='zen') +    async def zen_command(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_command.error +    @card_command.error +    @video_command.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/seasons/evergreen/snakes/utils.py b/bot/seasons/evergreen/snakes/utils.py new file mode 100644 index 00000000..605c7ef3 --- /dev/null +++ b/bot/seasons/evergreen/snakes/utils.py @@ -0,0 +1,700 @@ +""" +Perlin noise implementation. +Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 +Licensed under ISC +""" +import asyncio +import io +import json +import logging +import math +import random +from itertools import product +from pathlib import Path +from typing import List, Tuple + +import aiohttp +from PIL import Image +from PIL.ImageDraw import ImageDraw +from discord import File, Member, Reaction +from discord.ext.commands import Context + +SNAKE_RESOURCES = Path('bot', 'resources', 'snakes').absolute() + +h1 = r'''``` +        ---- +       ------ +     /--------\ +     |--------| +     |--------| +      \------/ +        ----```''' +h2 = r'''``` +        ---- +       ------ +     /---\-/--\ +     |-----\--| +     |--------| +      \------/ +        ----```''' +h3 = r'''``` +        ---- +       ------ +     /---\-/--\ +     |-----\--| +     |-----/--| +      \----\-/ +        ----```''' +h4 = r'''``` +        ----- +       -----  \ +     /--|  /---\ +     |--\  -\---| +     |--\--/--  / +      \------- / +        ------```''' +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" +} + +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) +# The size of the image to download +# Should a power of 2 and higher than BOARD_PLAYER_SIZE +PLAYER_ICON_IMAGE_SIZE = 32 +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 +} + +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 get_resource(file: str) -> List[dict]: +    with (SNAKE_RESOURCES / f"{file}.json").open() as snakefile: +        return json.load(snakefile) + + +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 + + +def create_snek_frame( +        perlin_factory: 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() + + +log = logging.getLogger(__name__) +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( +                str(SNAKE_RESOURCES / "snakes_and_ladders" / "banner.jpg"), +                # os.path.join("bot", "resources", "snakes", "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", "snakes_and_ladders", "board.jpg")) +        board_img = Image.open(str(SNAKE_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  |