diff options
| author | 2021-09-06 13:11:27 +0100 | |
|---|---|---|
| committer | 2021-09-06 13:11:27 +0100 | |
| commit | 5e136d9aadcdb334f502cebb6e85981394802324 (patch) | |
| tree | 58abafa6a5acd9938fd291947c7c9ce8bd949938 /bot/exts/evergreen/duck_game.py | |
| parent | Set logging level for async-rediscache to warning (diff) | |
| parent | Merge pull request #853 from python-discord/post-restructure-fix (diff) | |
Merge branch 'main' into async-redisache-logs
Diffstat (limited to 'bot/exts/evergreen/duck_game.py')
| -rw-r--r-- | bot/exts/evergreen/duck_game.py | 356 | 
1 files changed, 0 insertions, 356 deletions
| diff --git a/bot/exts/evergreen/duck_game.py b/bot/exts/evergreen/duck_game.py deleted file mode 100644 index d592f3df..00000000 --- a/bot/exts/evergreen/duck_game.py +++ /dev/null @@ -1,356 +0,0 @@ -import asyncio -import random -import re -from collections import defaultdict -from io import BytesIO -from itertools import product -from pathlib import Path -from urllib.parse import urlparse - -import discord -from PIL import Image, ImageDraw, ImageFont -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, MODERATION_ROLES -from bot.utils.decorators import with_role - - -DECK = list(product(*[(0, 1, 2)]*4)) - -GAME_DURATION = 180 - -# Scoring -CORRECT_SOLN = 1 -INCORRECT_SOLN = -1 -CORRECT_GOOSE = 2 -INCORRECT_GOOSE = -1 - -# Distribution of minimum acceptable solutions at board generation. -# This is for gameplay reasons, to shift the number of solutions per board up, -# while still making the end of the game unpredictable. -# Note: this is *not* the same as the distribution of number of solutions. - -SOLN_DISTR = 0, 0.05, 0.05, 0.1, 0.15, 0.25, 0.2, 0.15, .05 - -IMAGE_PATH = Path("bot", "resources", "evergreen", "all_cards.png") -FONT_PATH = Path("bot", "resources", "evergreen", "LuckiestGuy-Regular.ttf") -HELP_IMAGE_PATH = Path("bot", "resources", "evergreen", "ducks_help_ex.png") - -ALL_CARDS = Image.open(IMAGE_PATH) -LABEL_FONT = ImageFont.truetype(str(FONT_PATH), size=16) -CARD_WIDTH = 155 -CARD_HEIGHT = 97 - -EMOJI_WRONG = "\u274C" - -ANSWER_REGEX = re.compile(r'^\D*(\d+)\D+(\d+)\D+(\d+)\D*$') - -HELP_TEXT = """ -**Each card has 4 features** -Color, Number, Hat, and Accessory - -**A valid flight** -3 cards where each feature is either all the same or all different - -**Call "GOOSE"** -if you think there are no more flights - -**+1** for each valid flight -**+2** for a correct "GOOSE" call -**-1** for any wrong answer - -The first flight below is invalid: the first card has swords while the other two have no accessory.\ - It would be valid if the first card was empty-handed, or one of the other two had paintbrushes. - -The second flight is valid because there are no 2:1 splits; each feature is either all the same or all different. -""" - - -def assemble_board_image(board: list[tuple[int]], rows: int, columns: int) -> Image: -    """Cut and paste images representing the given cards into an image representing the board.""" -    new_im = Image.new("RGBA", (CARD_WIDTH*columns, CARD_HEIGHT*rows)) -    draw = ImageDraw.Draw(new_im) -    for idx, card in enumerate(board): -        card_image = get_card_image(card) -        row, col = divmod(idx, columns) -        top, left = row * CARD_HEIGHT, col * CARD_WIDTH -        new_im.paste(card_image, (left, top)) -        draw.text( -            xy=(left+5, top+5),  # magic numbers are buffers for the card labels -            text=str(idx), -            fill=(0, 0, 0), -            font=LABEL_FONT, -        ) -    return new_im - - -def get_card_image(card: tuple[int]) -> Image: -    """Slice the image containing all the cards to get just this card.""" -    # The master card image file should have 9x9 cards, -    # arranged such that their features can be interpreted as ordered trinary. -    row, col = divmod(as_trinary(card), 9) -    x1 = col * CARD_WIDTH -    x2 = x1 + CARD_WIDTH -    y1 = row * CARD_HEIGHT -    y2 = y1 + CARD_HEIGHT -    return ALL_CARDS.crop((x1, y1, x2, y2)) - - -def as_trinary(card: tuple[int]) -> int: -    """Find the card's unique index by interpreting its features as trinary.""" -    return int(''.join(str(x) for x in card), base=3) - - -class DuckGame: -    """A class for a single game.""" - -    def __init__( -        self, -        rows: int = 4, -        columns: int = 3, -        minimum_solutions: int = 1, -    ): -        """ -        Take samples from the deck to generate a board. - -        Args: -            rows (int, optional): Rows in the game board. Defaults to 4. -            columns (int, optional): Columns in the game board. Defaults to 3. -            minimum_solutions (int, optional): Minimum acceptable number of solutions in the board. Defaults to 1. -        """ -        self.rows = rows -        self.columns = columns -        size = rows * columns - -        self._solutions = None -        self.claimed_answers = {} -        self.scores = defaultdict(int) -        self.editing_embed = asyncio.Lock() - -        self.board = random.sample(DECK, size) -        while len(self.solutions) < minimum_solutions: -            self.board = random.sample(DECK, size) - -    @property -    def board(self) -> list[tuple[int]]: -        """Accesses board property.""" -        return self._board - -    @board.setter -    def board(self, val: list[tuple[int]]) -> None: -        """Erases calculated solutions if the board changes.""" -        self._solutions = None -        self._board = val - -    @property -    def solutions(self) -> None: -        """Calculate valid solutions and cache to avoid redoing work.""" -        if self._solutions is None: -            self._solutions = set() -            for idx_a, card_a in enumerate(self.board): -                for idx_b, card_b in enumerate(self.board[idx_a+1:], start=idx_a+1): -                    # Two points determine a line, and there are exactly 3 points per line in {0,1,2}^4. -                    # The completion of a line will only be a duplicate point if the other two points are the same, -                    # which is prevented by the triangle iteration. -                    completion = tuple( -                        feat_a if feat_a == feat_b else 3-feat_a-feat_b -                        for feat_a, feat_b in zip(card_a, card_b) -                    ) -                    try: -                        idx_c = self.board.index(completion) -                    except ValueError: -                        continue - -                    # Indices within the solution are sorted to detect duplicate solutions modulo order. -                    solution = tuple(sorted((idx_a, idx_b, idx_c))) -                    self._solutions.add(solution) - -        return self._solutions - - -class DuckGamesDirector(commands.Cog): -    """A cog for running Duck Duck Duck Goose games.""" - -    def __init__(self, bot: Bot): -        self.bot = bot -        self.current_games = {} - -    @commands.group( -        name='duckduckduckgoose', -        aliases=['dddg', 'ddg', 'duckduckgoose', 'duckgoose'], -        invoke_without_command=True -    ) -    @commands.cooldown(rate=1, per=2, type=commands.BucketType.channel) -    async def start_game(self, ctx: commands.Context) -> None: -        """Generate a board, send the game embed, and end the game after a time limit.""" -        if ctx.channel.id in self.current_games: -            await ctx.send("There's already a game running!") -            return - -        minimum_solutions, = random.choices(range(len(SOLN_DISTR)), weights=SOLN_DISTR) -        game = DuckGame(minimum_solutions=minimum_solutions) -        game.running = True -        self.current_games[ctx.channel.id] = game - -        game.embed_msg = await self.send_board_embed(ctx, game) -        await asyncio.sleep(GAME_DURATION) - -        # Checking for the channel ID in the currently running games is not sufficient. -        # The game could have been ended by a player, and a new game already started in the same channel. -        if game.running: -            try: -                del self.current_games[ctx.channel.id] -                await self.end_game(ctx.channel, game, end_message="Time's up!") -            except KeyError: -                pass - -    @commands.Cog.listener() -    async def on_message(self, msg: discord.Message) -> None: -        """Listen for messages and process them as answers if appropriate.""" -        if msg.author.bot: -            return - -        channel = msg.channel -        if channel.id not in self.current_games: -            return - -        game = self.current_games[channel.id] -        if msg.content.strip().lower() == 'goose': -            # If all of the solutions have been claimed, i.e. the "goose" call is correct. -            if len(game.solutions) == len(game.claimed_answers): -                try: -                    del self.current_games[channel.id] -                    game.scores[msg.author] += CORRECT_GOOSE -                    await self.end_game(channel, game, end_message=f"{msg.author.display_name} GOOSED!") -                except KeyError: -                    pass -            else: -                await msg.add_reaction(EMOJI_WRONG) -                game.scores[msg.author] += INCORRECT_GOOSE -            return - -        # Valid answers contain 3 numbers. -        if not (match := re.match(ANSWER_REGEX, msg.content)): -            return -        answer = tuple(sorted(int(m) for m in match.groups())) - -        # Be forgiving for answers that use indices not on the board. -        if not all(0 <= n < len(game.board) for n in answer): -            return - -        # Also be forgiving for answers that have already been claimed (and avoid penalizing for racing conditions). -        if answer in game.claimed_answers: -            return - -        if answer in game.solutions: -            game.claimed_answers[answer] = msg.author -            game.scores[msg.author] += CORRECT_SOLN -            await self.display_claimed_answer(game, msg.author, answer) -        else: -            await msg.add_reaction(EMOJI_WRONG) -            game.scores[msg.author] += INCORRECT_SOLN - -    async def send_board_embed(self, ctx: commands.Context, game: DuckGame) -> discord.Message: -        """Create and send the initial game embed. This will be edited as the game goes on.""" -        image = assemble_board_image(game.board, game.rows, game.columns) -        with BytesIO() as image_stream: -            image.save(image_stream, format="png") -            image_stream.seek(0) -            file = discord.File(fp=image_stream, filename="board.png") -        embed = discord.Embed( -            title="Duck Duck Duck Goose!", -            color=Colours.bright_green, -            footer="" -        ) -        embed.set_image(url="attachment://board.png") -        return await ctx.send(embed=embed, file=file) - -    async def display_claimed_answer(self, game: DuckGame, author: discord.Member, answer: tuple[int]) -> None: -        """Add a claimed answer to the game embed.""" -        async with game.editing_embed: -            game_embed, = game.embed_msg.embeds -            old_footer = game_embed.footer.text -            if old_footer == discord.Embed.Empty: -                old_footer = "" -            game_embed.set_footer(text=f"{old_footer}\n{str(answer):12s}  -  {author.display_name}") -            await self.edit_embed_with_image(game.embed_msg, game_embed) - -    async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_message: str) -> None: -        """Edit the game embed to reflect the end of the game and mark the game as not running.""" -        game.running = False - -        scoreboard_embed = discord.Embed( -            title=end_message, -            color=discord.Color.dark_purple(), -        ) -        scores = sorted( -            game.scores.items(), -            key=lambda item: item[1], -            reverse=True, -        ) -        scoreboard = "Final scores:\n\n" -        scoreboard += "\n".join(f"{member.display_name}: {score}" for member, score in scores) -        scoreboard_embed.description = scoreboard -        await channel.send(embed=scoreboard_embed) - -        missed = [ans for ans in game.solutions if ans not in game.claimed_answers] -        if missed: -            missed_text = "Flights everyone missed:\n" + "\n".join(f"{ans}" for ans in missed) -        else: -            missed_text = "All the flights were found!" - -        game_embed, = game.embed_msg.embeds -        old_footer = game_embed.footer.text -        if old_footer == discord.Embed.Empty: -            old_footer = "" -        embed_as_dict = game_embed.to_dict()  # Cannot set embed color after initialization -        embed_as_dict["color"] = discord.Color.red().value -        game_embed = discord.Embed.from_dict(embed_as_dict) -        game_embed.set_footer( -            text=f"{old_footer.rstrip()}\n\n{missed_text}" -        ) -        await self.edit_embed_with_image(game.embed_msg, game_embed) - -    @start_game.command(name="help") -    async def show_rules(self, ctx: commands.Context) -> None: -        """Explain the rules of the game.""" -        await self.send_help_embed(ctx) - -    @start_game.command(name="stop") -    @with_role(*MODERATION_ROLES) -    async def stop_game(self, ctx: commands.Context) -> None: -        """Stop a currently running game. Only available to mods.""" -        try: -            game = self.current_games.pop(ctx.channel.id) -        except KeyError: -            await ctx.send("No game currently running in this channel") -            return -        await self.end_game(ctx.channel, game, end_message="Game canceled.") - -    @staticmethod -    async def send_help_embed(ctx: commands.Context) -> discord.Message: -        """Send rules embed.""" -        embed = discord.Embed( -            title="Compete against other players to find valid flights!", -            color=discord.Color.dark_purple(), -        ) -        embed.description = HELP_TEXT -        file = discord.File(HELP_IMAGE_PATH, filename="help.png") -        embed.set_image(url="attachment://help.png") -        embed.set_footer( -            text="Tip: using Discord's compact message display mode can help keep the board on the screen" -        ) -        return await ctx.send(file=file, embed=embed) - -    @staticmethod -    async def edit_embed_with_image(msg: discord.Message, embed: discord.Embed) -> None: -        """Edit an embed without the attached image going wonky.""" -        attach_name = urlparse(embed.image.url).path.split("/")[-1] -        embed.set_image(url=f"attachment://{attach_name}") -        await msg.edit(embed=embed) - - -def setup(bot: Bot) -> None: -    """Load the DuckGamesDirector cog.""" -    bot.add_cog(DuckGamesDirector(bot)) | 
