diff options
| -rw-r--r-- | bot/constants.py | 17 | ||||
| -rw-r--r-- | bot/exts/evergreen/tic_tac_toe.py | 323 | 
2 files changed, 340 insertions, 0 deletions
| diff --git a/bot/constants.py b/bot/constants.py index d63a063e..1d41a53e 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -174,6 +174,23 @@ class Emojis:      pull_request_closed = "<:PRClosed:629695470519713818>"      merge = "<:PRMerged:629695470570176522>" +    # TicTacToe Emojis +    number_emojis = { +        1: "\u0031\ufe0f\u20e3", +        2: "\u0032\ufe0f\u20e3", +        3: "\u0033\ufe0f\u20e3", +        4: "\u0034\ufe0f\u20e3", +        5: "\u0035\ufe0f\u20e3", +        6: "\u0036\ufe0f\u20e3", +        7: "\u0037\ufe0f\u20e3", +        8: "\u0038\ufe0f\u20e3", +        9: "\u0039\ufe0f\u20e3" +    } +    confirmation = "\u2705" +    decline = "\u274c" +    x = "\U0001f1fd" +    o = "\U0001f1f4" +      status_online = "<:status_online:470326272351010816>"      status_idle = "<:status_idle:470326266625785866>"      status_dnd = "<:status_dnd:470326272082313216>" diff --git a/bot/exts/evergreen/tic_tac_toe.py b/bot/exts/evergreen/tic_tac_toe.py new file mode 100644 index 00000000..e1190502 --- /dev/null +++ b/bot/exts/evergreen/tic_tac_toe.py @@ -0,0 +1,323 @@ +import asyncio +import random +import typing as t + +import discord +from discord.ext.commands import Cog, Context, check, group, guild_only + +from bot.bot import Bot +from bot.constants import Emojis +from bot.utils.pagination import LinePaginator + +CONFIRMATION_MESSAGE = ( +    "{opponent}, {requester} wants to play Tic-Tac-Toe against you. React to this message with " +    f"{Emojis.confirmation} to accept or with {Emojis.decline} to decline." +) + + +def check_win(board: t.Dict[int, str]) -> bool: +    """Check from board, is any player won game.""" +    return any( +        ( +            # Horizontal +            board[1] == board[2] == board[3], +            board[4] == board[5] == board[6], +            board[7] == board[8] == board[9], +            # Vertical +            board[1] == board[4] == board[7], +            board[2] == board[5] == board[8], +            board[3] == board[6] == board[9], +            # Diagonal +            board[1] == board[5] == board[9], +            board[3] == board[5] == board[7], +        ) +    ) + + +class Player: +    """Class that contains information about player and functions that interact with player.""" + +    def __init__(self, user: discord.User, ctx: Context, symbol: str): +        self.user = user +        self.ctx = ctx +        self.symbol = symbol + +    async def get_move(self, board: t.Dict[int, str], msg: discord.Message) -> t.Tuple[bool, t.Optional[int]]: +        """ +        Get move from user. + +        Return is timeout reached and position of field what user will fill when timeout don't reach. +        """ +        def check_for_move(r: discord.Reaction, u: discord.User) -> bool: +            """Check does user who reacted is user who we want, message is board and emoji is in board values.""" +            return ( +                u.id == self.user.id +                and msg.id == r.message.id +                and r.emoji in board.values() +                and r.emoji in Emojis.number_emojis.values() +            ) + +        try: +            react, _ = await self.ctx.bot.wait_for('reaction_add', timeout=30.0, check=check_for_move) +        except asyncio.TimeoutError: +            return True, None +        else: +            return False, list(Emojis.number_emojis.keys())[list(Emojis.number_emojis.values()).index(react.emoji)] + +    def __str__(self) -> str: +        """Return mention of user.""" +        return self.user.mention + + +class AI: +    """Tic Tac Toe AI class for against computer gaming.""" + +    def __init__(self, symbol: str): +        self.symbol = symbol + +    async def get_move(self, board: t.Dict[int, str], _: discord.Message) -> t.Tuple[bool, int]: +        """Get move from AI. AI use Minimax strategy.""" +        possible_moves = [i for i, emoji in board.items() if emoji in list(Emojis.number_emojis.values())] + +        for symbol in (Emojis.o, Emojis.x): +            for move in possible_moves: +                board_copy = board.copy() +                board_copy[move] = symbol +                if check_win(board_copy): +                    return False, move + +        open_corners = [i for i in possible_moves if i in (1, 3, 7, 9)] +        if len(open_corners) > 0: +            return False, random.choice(open_corners) + +        if 5 in possible_moves: +            return False, 5 + +        open_edges = [i for i in possible_moves if i in (2, 4, 6, 8)] +        return False, random.choice(open_edges) + +    def __str__(self) -> str: +        """Return `AI` as user name.""" +        return "AI" + + +class Game: +    """Class that contains information and functions about Tic Tac Toe game.""" + +    def __init__(self, players: t.List[t.Union[Player, AI]], ctx: Context): +        self.players = players +        self.ctx = ctx +        self.board = { +            1: Emojis.number_emojis[1], +            2: Emojis.number_emojis[2], +            3: Emojis.number_emojis[3], +            4: Emojis.number_emojis[4], +            5: Emojis.number_emojis[5], +            6: Emojis.number_emojis[6], +            7: Emojis.number_emojis[7], +            8: Emojis.number_emojis[8], +            9: Emojis.number_emojis[9] +        } + +        self.current = self.players[0] +        self.next = self.players[1] + +        self.winner: t.Optional[t.Union[Player, AI]] = None +        self.loser: t.Optional[t.Union[Player, AI]] = None +        self.over = False +        self.canceled = False +        self.draw = False + +    async def get_confirmation(self) -> t.Tuple[bool, t.Optional[str]]: +        """ +        Ask does user want to play TicTacToe against requester. First player is always requester. + +        This return tuple that have: +        - first element boolean (is game accepted?) +        - (optional, only when first element is False, otherwise None) reason for declining. +        """ +        confirm_message = await self.ctx.send( +            CONFIRMATION_MESSAGE.format( +                opponent=self.players[1].user.mention, +                requester=self.players[0].user.mention +            ) +        ) +        await confirm_message.add_reaction(Emojis.confirmation) +        await confirm_message.add_reaction(Emojis.decline) + +        def confirm_check(reaction: discord.Reaction, user: discord.User) -> bool: +            """Check is user who reacted from who this was requested, message is confirmation and emoji is valid.""" +            return ( +                reaction.emoji in (Emojis.confirmation, Emojis.decline) +                and reaction.message.id == confirm_message.id +                and user == self.players[1].user +            ) + +        try: +            reaction, user = await self.ctx.bot.wait_for( +                "reaction_add", +                timeout=60.0, +                check=confirm_check +            ) +        except asyncio.TimeoutError: +            self.over = True +            self.canceled = True +            await confirm_message.delete() +            return False, "Running out of time... Cancelled game." + +        await confirm_message.delete() +        if reaction.emoji == Emojis.confirmation: +            return True, None +        else: +            self.over = True +            self.canceled = True +            return False, "User declined" + +    async def add_reactions(self, msg: discord.Message) -> None: +        """Add number emojis to message.""" +        for nr in Emojis.number_emojis.values(): +            await msg.add_reaction(nr) + +    def format_board(self) -> str: +        """Get formatted tic-tac-toe board for message.""" +        board = list(self.board.values()) +        return "\n".join( +            (f"{board[line]} {board[line + 1]} {board[line + 2]}" for line in range(0, len(board), 3)) +        ) + +    async def play(self) -> None: +        """Start and handle game.""" +        await self.ctx.send("It's time for the game! Let's begin.") +        board = await self.ctx.send( +            embed=discord.Embed(description=self.format_board()) +        ) +        await self.add_reactions(board) + +        for _ in range(9): +            if isinstance(self.current, Player): +                announce = await self.ctx.send( +                    f"{self.current.user.mention}, it's your turn! " +                    "React with an emoji to take your go." +                ) +            timeout, pos = await self.current.get_move(self.board, board) +            if isinstance(self.current, Player): +                await announce.delete() +            if timeout: +                await self.ctx.send(f"{self.current.user.mention} ran out of time. Canceling game.") +                self.over = True +                self.canceled = True +                return +            self.board[pos] = self.current.symbol +            await board.edit( +                embed=discord.Embed(description=self.format_board()) +            ) +            await board.clear_reaction(Emojis.number_emojis[pos]) +            if check_win(self.board): +                self.winner = self.current +                self.loser = self.next +                await self.ctx.send( +                    f":tada: {self.current} won this game! :tada:" +                ) +                await board.clear_reactions() +                break +            self.current, self.next = self.next, self.current +        if not self.winner: +            self.draw = True +            await self.ctx.send("It's a DRAW!") +        self.over = True + + +def is_channel_free() -> t.Callable: +    """Check is channel where command will be invoked free.""" +    async def predicate(ctx: Context) -> bool: +        return all(game.channel != ctx.channel for game in ctx.cog.games if not game.over) +    return check(predicate) + + +def is_requester_free() -> t.Callable: +    """Check is requester not already in any game.""" +    async def predicate(ctx: Context) -> bool: +        return all( +            ctx.author not in (player.user for player in game.players) for game in ctx.cog.games if not game.over +        ) +    return check(predicate) + + +class TicTacToe(Cog): +    """TicTacToe cog contains tic-tac-toe game commands.""" + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.games: t.List[Game] = [] + +    @guild_only() +    @is_channel_free() +    @is_requester_free() +    @group(name="tictactoe", aliases=("ttt",), invoke_without_command=True) +    async def tic_tac_toe(self, ctx: Context, opponent: t.Optional[discord.User]) -> None: +        """Tic Tac Toe game. Play against friends or AI. Use reactions to add your mark to field.""" +        if opponent == ctx.author: +            await ctx.send("You can't play against yourself.") +            return +        if opponent is not None and not all( +            opponent not in (player.user for player in g.players) for g in ctx.cog.games if not g.over +        ): +            await ctx.send("Opponent is already in game.") +            return +        if opponent is None: +            game = Game( +                [Player(ctx.author, ctx, Emojis.x), AI(Emojis.o)], +                ctx +            ) +        else: +            game = Game( +                [Player(ctx.author, ctx, Emojis.x), Player(opponent, ctx, Emojis.o)], +                ctx +            ) +        self.games.append(game) +        if opponent is not None: +            confirmed, msg = await game.get_confirmation() + +            if not confirmed: +                if msg: +                    await ctx.send(msg) +                return +        await game.play() + +    @tic_tac_toe.group(name="history", aliases=("log",), invoke_without_command=True) +    async def tic_tac_toe_logs(self, ctx: Context) -> None: +        """Show most recent tic-tac-toe games.""" +        if len(self.games) < 1: +            await ctx.send("No recent games.") +            return +        log_games = [] +        for i, game in enumerate(self.games): +            if game.over and not game.canceled: +                if game.draw: +                    log_games.append( +                        f"**#{i+1}**: {game.players[0]} vs {game.players[1]} (draw)" +                    ) +                else: +                    log_games.append( +                        f"**#{i+1}**: {game.winner} :trophy: vs {game.loser}" +                    ) +        await LinePaginator.paginate( +            log_games, +            ctx, +            discord.Embed(title="Most recent Tic Tac Toe games") +        ) + +    @tic_tac_toe_logs.command(name="show", aliases=("s",)) +    async def show_tic_tac_toe_board(self, ctx: Context, game_id: int) -> None: +        """View game board by ID (ID is possible to get by `.tictactoe history`).""" +        if len(self.games) < game_id: +            await ctx.send("Game don't exist.") +            return +        game = self.games[game_id - 1] +        await ctx.send(f"{game.winner} :trophy: vs {game.loser}") +        await ctx.send(game.format_board()) + + +def setup(bot: Bot) -> None: +    """Load TicTacToe Cog.""" +    bot.add_cog(TicTacToe(bot)) | 
