diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py index 4e23c0798e..ed2f480e29 100644 --- a/bot/exts/fun/anagram.py +++ b/bot/exts/fun/anagram.py @@ -9,6 +9,10 @@ from bot.bot import Bot from bot.constants import Colours +from bot.utils.leaderboard import add_points + +ANAGRAM_WIN_POINTS = 10 +ANAGRAM_GAME_NAME = "anagram" log = get_logger(__name__) @@ -32,11 +36,13 @@ def __init__(self, scrambled: str, correct: list[str]) -> None: self.correct = set(correct) self.winners = set() + self.winner_ids = set() async def message_creation(self, message: discord.Message) -> None: """Check if the message is a correct answer and remove it from the list of answers.""" if message.content.lower() in self.correct: self.winners.add(message.author.mention) + self.winner_ids.add(message.author.id) self.correct.remove(message.content.lower()) @@ -77,7 +83,15 @@ async def anagram_command(self, ctx: commands.Context) -> None: if game.winners: win_list = ", ".join(game.winners) - content = f"Well done {win_list} for getting it right!" + points_earned = set() + for winner_id in game.winner_ids: + _, earned = await add_points(self.bot, winner_id, ANAGRAM_WIN_POINTS, ANAGRAM_GAME_NAME) + points_earned.add(earned) + if len(points_earned) == 1: + pts = points_earned.pop() + content = f"Well done {win_list} for getting it right! (+{pts} pts)" + else: + content = f"Well done {win_list} for getting it right!" else: content = "Nobody got it right." diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py index f711074cbe..1469f2036d 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -9,6 +9,11 @@ from bot.bot import Bot from bot.constants import Colours, Emojis +from bot.utils.leaderboard import add_points + +BATTLESHIP_WIN_POINTS = 30 +BATTLESHIP_GAME_NAME = "battleship" + log = get_logger(__name__) @@ -150,11 +155,13 @@ async def game_over( loser: discord.Member ) -> None: """Removes games from list of current games and announces to public chat.""" - await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention}") + _, earned = await add_points(self.bot, winner.id, BATTLESHIP_WIN_POINTS, BATTLESHIP_GAME_NAME) + header = f"Game Over! {winner.mention} won against {loser.mention} (+{earned} pts)" - for player in (self.p1, self.p2): + for i, player in enumerate((self.p1, self.p2)): grid = self.format_grid(player, SHIP_EMOJIS) - await self.public_channel.send(f"{player.user}'s Board:\n{grid}") + content = f"{header}\n{player.user}'s Board:\n{grid}" if i == 0 else f"{player.user}'s Board:\n{grid}" + await self.public_channel.send(content) @staticmethod def check_sink(grid: Grid, boat: str) -> bool: @@ -245,16 +252,22 @@ async def take_turn(self) -> Square | None: except TimeoutError: await self.turn.user.send("You took too long. Game over!") await self.next.user.send(f"{self.turn.user} took too long. Game over!") + _, earned = await add_points(self.bot, self.next.user.id, BATTLESHIP_WIN_POINTS, BATTLESHIP_GAME_NAME) await self.public_channel.send( - f"Game over! {self.turn.user.mention} timed out so {self.next.user.mention} wins!" + f"Game over! {self.turn.user.mention} timed out so {self.next.user.mention} wins! " + f"(+{earned} pts)" ) self.gameover = True break else: if self.surrender: await self.next.user.send(f"{self.turn.user} surrendered. Game over!") + _, earned = await add_points( + self.bot, self.next.user.id, BATTLESHIP_WIN_POINTS, BATTLESHIP_GAME_NAME + ) await self.public_channel.send( - f"Game over! {self.turn.user.mention} surrendered to {self.next.user.mention}!" + f"Game over! {self.turn.user.mention} surrendered to {self.next.user.mention}! " + f"(+{earned} pts)" ) self.gameover = True break @@ -274,11 +287,13 @@ async def hit(self, square: Square, alert_messages: list[discord.Message]) -> No await self.turn.user.send(f"You've sunk their {square.boat} ship!", delete_after=3.0) alert_messages.append(await self.next.user.send(f"Oh no! Your {square.boat} ship sunk!")) if self.check_gameover(self.next.grid): - await self.turn.user.send("You win!") + _, earned = await add_points(self.bot, self.turn.user.id, BATTLESHIP_WIN_POINTS, BATTLESHIP_GAME_NAME) + await self.turn.user.send(f"You win! (+{earned} pts)") await self.next.user.send("You lose!") self.gameover = True await self.game_over(winner=self.turn.user, loser=self.next.user) + async def start_game(self) -> None: """Begins the game.""" await self.p1.user.send(f"You're playing battleship with {self.p2.user}.") diff --git a/bot/exts/fun/coinflip.py b/bot/exts/fun/coinflip.py index b7dee44dd1..5368533935 100644 --- a/bot/exts/fun/coinflip.py +++ b/bot/exts/fun/coinflip.py @@ -4,6 +4,10 @@ from bot.bot import Bot from bot.constants import Emojis +from bot.utils.leaderboard import add_points + +COINFLIP_WIN_POINTS = 2 +COINFLIP_GAME_NAME = "coinflip" class CoinSide(commands.Converter): @@ -27,6 +31,9 @@ async def convert(self, ctx: commands.Context, side: str) -> str: class CoinFlip(commands.Cog): """Cog for the CoinFlip command.""" + def __init__(self, bot: Bot): + self.bot = bot + @commands.command(name="coinflip", aliases=("flip", "coin", "cf")) async def coinflip_command(self, ctx: commands.Context, side: CoinSide = None) -> None: """ @@ -42,7 +49,8 @@ async def coinflip_command(self, ctx: commands.Context, side: CoinSide = None) - return if side == flipped_side: - message += f"You guessed correctly! {Emojis.lemon_hyperpleased}" + _, earned = await add_points(self.bot, ctx.author.id, COINFLIP_WIN_POINTS, COINFLIP_GAME_NAME) + message += f"You guessed correctly! {Emojis.lemon_hyperpleased} (+{earned} pts)" else: message += f"You guessed incorrectly. {Emojis.lemon_pensive}" await ctx.send(message) @@ -50,4 +58,4 @@ async def coinflip_command(self, ctx: commands.Context, side: CoinSide = None) - async def setup(bot: Bot) -> None: """Loads the coinflip cog.""" - await bot.add_cog(CoinFlip()) + await bot.add_cog(CoinFlip(bot)) diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index 5e45c57659..60b0b8abcf 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -8,6 +8,10 @@ from bot.bot import Bot from bot.constants import Emojis +from bot.utils.leaderboard import add_points + +CONNECT_FOUR_WIN_POINTS = 15 +CONNECT_FOUR_GAME_NAME = "connect_four" NUMBERS = list(Emojis.number_emojis.values()) CROSS_EMOJI = Emojis.incident_unactioned @@ -75,11 +79,27 @@ async def game_over( ) -> None: """Announces to public chat.""" if action == "win": - await self.channel.send(f"Game Over! {player1.mention} won against {player2.mention}") + if isinstance(player1, Member): + _, earned = await add_points(self.bot, player1.id, CONNECT_FOUR_WIN_POINTS, CONNECT_FOUR_GAME_NAME) + await self.channel.send( + f"Game Over! {player1.mention} won against {player2.mention} (+{earned} pts)" + ) + else: + await self.channel.send( + f"Game Over! {player1.mention} won against {player2.mention}" + ) elif action == "draw": await self.channel.send(f"Game Over! {player1.mention} {player2.mention} It's A Draw :tada:") elif action == "quit": - await self.channel.send(f"{self.player1.mention} surrendered. Game over!") + if isinstance(player2, Member): + _, earned = await add_points(self.bot, player2.id, CONNECT_FOUR_WIN_POINTS, CONNECT_FOUR_GAME_NAME) + await self.channel.send( + f"{player1.mention} surrendered. {player2.mention} wins! Game over! (+{earned} pts)" + ) + else: + await self.channel.send( + f"{player1.mention} surrendered. {player2.mention} wins! Game over!" + ) await self.print_grid() async def start_game(self) -> None: @@ -131,7 +151,16 @@ async def player_turn(self) -> Coordinate: try: reaction, user = await self.bot.wait_for("reaction_add", check=self.predicate, timeout=30.0) except TimeoutError: - await self.channel.send(f"{self.player_active.mention}, you took too long. Game over!") + message = ( + f"{self.player_active.mention}, you took too long. Game over! " + f"{self.player_inactive.mention} wins!" + ) + if isinstance(self.player_inactive, Member): + _, earned = await add_points( + self.bot, self.player_inactive.id, CONNECT_FOUR_WIN_POINTS, CONNECT_FOUR_GAME_NAME + ) + message += f" (+{earned} pts)" + await self.channel.send(message) return None else: await message.delete() diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index fbdc9ea2ee..086c6d43d5 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -13,6 +13,17 @@ from bot.bot import Bot from bot.constants import MODERATION_ROLES from bot.utils.decorators import with_role +from bot.utils.leaderboard import add_points + +DUCK_GAME_FIRST_PLACE_POINTS = 30 +DUCK_GAME_SECOND_PLACE_POINTS = 20 +DUCK_GAME_THIRD_PLACE_POINTS = 10 +DUCK_GAME_POINT_AWARDS = ( + DUCK_GAME_FIRST_PLACE_POINTS, + DUCK_GAME_SECOND_PLACE_POINTS, + DUCK_GAME_THIRD_PLACE_POINTS, +) +DUCK_GAME_NAME = "duck_game" DECK = list(product(*[(0, 1, 2)]*4)) @@ -297,8 +308,22 @@ async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_messa key=lambda item: item[1], reverse=True, ) + + # Award leaderboard points to top finishers (number of places from DUCK_GAME_POINT_AWARDS) + earned_points = {} + for rank, (member, score) in enumerate(scores[:len(DUCK_GAME_POINT_AWARDS)]): + if score > 0: + _, earned = await add_points( + self.bot, member.id, DUCK_GAME_POINT_AWARDS[rank], DUCK_GAME_NAME + ) + earned_points[member.id] = earned + scoreboard = "Final scores:\n\n" - scoreboard += "\n".join(f"{member.display_name}: {score}" for member, score in scores) + for rank, (member, score) in enumerate(scores): + if rank < len(DUCK_GAME_POINT_AWARDS) and score > 0 and member.id in earned_points: + scoreboard += f"{member.display_name}: {score} (+{earned_points[member.id]} pts)\n" + else: + scoreboard += f"{member.display_name}: {score}\n" scoreboard_embed.description = scoreboard await channel.send(embed=scoreboard_embed) diff --git a/bot/exts/fun/hangman.py b/bot/exts/fun/hangman.py index 256ff9012e..6e36ad10c0 100644 --- a/bot/exts/fun/hangman.py +++ b/bot/exts/fun/hangman.py @@ -6,6 +6,10 @@ from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES +from bot.utils.leaderboard import add_points + +HANGMAN_WIN_POINTS = 15 +HANGMAN_GAME_NAME = "hangman" # Defining all words in the list of words as a global variable ALL_WORDS = Path("bot/resources/fun/hangman_words.txt").read_text().splitlines() @@ -167,9 +171,10 @@ def check(msg: Message) -> bool: # The loop exited meaning that the user has guessed the word await original_message.edit(embed=self.create_embed(tries, user_guess)) + _, earned = await add_points(self.bot, ctx.author.id, HANGMAN_WIN_POINTS, HANGMAN_GAME_NAME) win_embed = Embed( title="You won!", - description=f"The word was `{word}`.", + description=f"The word was `{word}`. (+{earned} pts)", color=Colours.grass_green ) await ctx.send(embed=win_embed) diff --git a/bot/exts/fun/leaderboard.py b/bot/exts/fun/leaderboard.py new file mode 100644 index 0000000000..86fd83c64f --- /dev/null +++ b/bot/exts/fun/leaderboard.py @@ -0,0 +1,201 @@ +import discord +from async_rediscache import RedisCache +from discord import ButtonStyle, Interaction, ui +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, MODERATION_ROLES +from bot.utils.decorators import with_role +from bot.utils.leaderboard import POINTS_CACHE, get_daily_leaderboard, get_leaderboard, get_user_points, get_user_rank +from bot.utils.pagination import LinePaginator + +DUCK_COIN_THUMBNAIL = ( + "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/fun/duck-coin.png" +) + +MEDALS = ( + "\N{FIRST PLACE MEDAL}", + "\N{SECOND PLACE MEDAL}", + "\N{THIRD PLACE MEDAL}", +) + + +def _format_leaderboard_lines(records: list[tuple[int, int]]) -> list[str]: + """Format leaderboard records into display lines.""" + lines = [] + prev_score = None + rank = 0 + + for position, (user_id, score) in enumerate(records, start=1): + if score != prev_score: + rank = position + prev_score = score + if rank <= len(MEDALS): + prefix = MEDALS[rank - 1] + else: + prefix = f"**#{rank}**" + lines.append(f"{prefix} <@{user_id}>: **{score}** pts") + return lines + + +class ConfirmClear(ui.View): + """A confirmation view for clearing the leaderboard.""" + + def __init__(self, author_id: int) -> None: + super().__init__(timeout=15) + self.author_id = author_id + + async def interaction_check(self, interaction: Interaction) -> bool: + """Only the invoking admin can interact.""" + if interaction.user.id == self.author_id: + return True + await interaction.response.send_message( + "You are not authorized to perform this action.", + ephemeral=True, + ) + return False + + @ui.button(label="Confirm", style=ButtonStyle.danger) + async def confirm(self, interaction: Interaction, _button: ui.Button) -> None: + """Clear the leaderboard on confirmation.""" + if POINTS_CACHE is None: + await interaction.response.send_message("Leaderboard cache is not initialized.") + self.stop() + return + + await POINTS_CACHE.clear() + await interaction.response.send_message("Leaderboard has been cleared.") + self.stop() + + @ui.button(label="Cancel", style=ButtonStyle.secondary) + async def cancel(self, interaction: Interaction, _button: ui.Button) -> None: + """Abort the clear operation.""" + await interaction.response.send_message("Clearing aborted.") + self.stop() + + +class Leaderboard(commands.Cog): + """Global leaderboard cog that tracks points across all games.""" + + points_cache = RedisCache(namespace="leaderboard:points") + + def __init__(self, bot: Bot): + self.bot = bot + + async def cog_load(self) -> None: + """Register the global cache when the cog loads.""" + from bot.utils import leaderboard + leaderboard.POINTS_CACHE = self.points_cache + + async def cog_unload(self) -> None: + """Reset the global cache when the cog unloads.""" + from bot.utils import leaderboard + leaderboard.POINTS_CACHE = None + + @commands.group(name="leaderboard", aliases=("lb", "points"), invoke_without_command=True) + async def leaderboard_command(self, ctx: commands.Context) -> None: + """Show the global game points leaderboard.""" + records = await get_leaderboard(self.bot) + + if not records: + await ctx.send("No one has earned any points yet. Play some games!") + return + + lines = _format_leaderboard_lines(records) + + embed = discord.Embed( + colour=Colours.gold, + title="Global Game Leaderboard", + ) + embed.set_thumbnail(url=DUCK_COIN_THUMBNAIL) + + user_score = await get_user_points(self.bot, ctx.author.id) + rank = await get_user_rank(self.bot, ctx.author.id, leaderboard=records) + if rank: + footer = f"Your rank: #{rank} | Your total: {user_score} pts" + else: + footer = "You're not on the leaderboard yet!" + + await LinePaginator.paginate( + lines, + ctx, + embed, + max_lines=10, + max_size=2000, + empty=False, + footer_text=footer, + ) + + @leaderboard_command.command(name="today", aliases=("t",)) + async def leaderboard_today(self, ctx: commands.Context) -> None: + """Show today's game points leaderboard.""" + records = await get_daily_leaderboard(self.bot) + + if not records: + await ctx.send("No one has earned any points today yet. Play some games!") + return + + lines = _format_leaderboard_lines(records) + + embed = discord.Embed( + colour=Colours.gold, + title="Today's Game Leaderboard", + ) + embed.set_thumbnail(url=DUCK_COIN_THUMBNAIL) + + user_score = await get_user_points(self.bot, ctx.author.id) + footer = f"Your total: {user_score} pts" + + await LinePaginator.paginate( + lines, + ctx, + embed, + max_lines=10, + max_size=2000, + empty=False, + footer_text=footer, + ) + + @leaderboard_command.command(name="me") + async def leaderboard_me(self, ctx: commands.Context) -> None: + """Show your own global points.""" + await self.leaderboard_user(ctx, ctx.author) + + @leaderboard_command.command(name="user") + async def leaderboard_user(self, ctx: commands.Context, user: discord.User) -> None: + """Show a specific user's global points.""" + score = await get_user_points(self.bot, user.id) + rank = await get_user_rank(self.bot, user.id) + + description = f"{user.mention}: **{score}** pts" + if rank: + description += f" (Rank #{rank})" + + embed = discord.Embed( + colour=Colours.blue, + title=f"{user.display_name}'s Global Points", + description=description, + ) + await ctx.send(embed=embed) + + @leaderboard_command.command(name="clear") + @with_role(*MODERATION_ROLES) + async def leaderboard_clear(self, ctx: commands.Context) -> None: + """Clear the global leaderboard (admin only).""" + view = ConfirmClear(ctx.author.id) + msg = await ctx.send( + "**Warning:** This will irreversibly clear the entire global leaderboard. Are you sure?", + view=view, + ) + timed_out = await view.wait() + if timed_out: + await msg.edit(content="Clearing aborted (timed out).", view=None) + else: + for child in view.children: + child.disabled = True + await msg.edit(view=view) + + +async def setup(bot: Bot) -> None: + """Load the Leaderboard cog.""" + await bot.add_cog(Leaderboard(bot)) diff --git a/bot/exts/fun/minesweeper.py b/bot/exts/fun/minesweeper.py index 29cd8ee9ba..940cf27c29 100644 --- a/bot/exts/fun/minesweeper.py +++ b/bot/exts/fun/minesweeper.py @@ -10,6 +10,10 @@ from bot.constants import Client from bot.utils.converters import CoordinateConverter from bot.utils.exceptions import UserNotPlayingError +from bot.utils.leaderboard import add_points + +MINESWEEPER_WIN_POINTS = 15 +MINESWEEPER_GAME_NAME = "minesweeper" MESSAGE_MAPPING = { 0: ":stop_button:", @@ -52,6 +56,18 @@ class Minesweeper(commands.Cog): def __init__(self, bot: Bot): self.bot = bot self.games: dict[int, Game] = {} + self.points_by_user: dict[int, int] = {} + + @staticmethod + def points_for_bomb_chance(bomb_chance: float) -> int: + """Calculate points awarded based on the bomb density of the board.""" + if bomb_chance <= 0.15: + return 15 + if bomb_chance <= 0.20: + return 17 + if bomb_chance <= 0.25: + return 20 + return 15 @commands.group(name="minesweeper", aliases=("ms",), invoke_without_command=True) async def minesweeper_group(self, ctx: commands.Context) -> None: @@ -123,7 +139,7 @@ async def start_command(self, ctx: commands.Context, bomb_chance: float = .2) -> log.debug(f"{ctx.author.name} ({ctx.author.id}) has disabled DMs from server members.") await ctx.send(f":x: {ctx.author.mention}, please enable DMs to play minesweeper.") return - + self.points_by_user[ctx.author.id] = self.points_for_bomb_chance(bomb_chance) # Add game to list board: GameBoard = self.generate_board(bomb_chance) revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)] @@ -183,10 +199,13 @@ async def lost(self, ctx: commands.Context) -> None: async def won(self, ctx: commands.Context) -> None: """The player won the game.""" game = self.games[ctx.author.id] - await ctx.author.send(":tada: You won! :tada:") + points = self.points_by_user.get(ctx.author.id, MINESWEEPER_WIN_POINTS) + _, earned = await add_points(self.bot, ctx.author.id, points, MINESWEEPER_GAME_NAME) + await ctx.author.send(f":tada: You won! :tada: (+{earned} pts)") if game.activated_on_server: - await game.chat_msg.channel.send(f":tada: {ctx.author.mention} just won Minesweeper! :tada:") - + await game.chat_msg.channel.send( + f":tada: {ctx.author.mention} just won Minesweeper! :tada: (+{points} pts)" + ) def reveal_zeros(self, revealed: GameBoard, board: GameBoard, x: int, y: int) -> None: """Recursively reveal adjacent cells when a 0 cell is encountered.""" for x_, y_ in self.get_neighbours(x, y): @@ -245,6 +264,7 @@ async def reveal_command(self, ctx: commands.Context, *coordinates: CoordinateCo if await self.reveal_one(ctx, revealed, board, x, y): await self.update_boards(ctx) del self.games[ctx.author.id] + self.points_by_user.pop(ctx.author.id, None) break else: await self.update_boards(ctx) @@ -262,6 +282,7 @@ async def end_command(self, ctx: commands.Context) -> None: if game.activated_on_server: await game.chat_msg.edit(content=new_msg) del self.games[ctx.author.id] + self.points_by_user.pop(ctx.author.id, None) async def setup(bot: Bot) -> None: diff --git a/bot/exts/fun/rps.py b/bot/exts/fun/rps.py index 501298355d..d82b2f0fd5 100644 --- a/bot/exts/fun/rps.py +++ b/bot/exts/fun/rps.py @@ -3,6 +3,10 @@ from discord.ext import commands from bot.bot import Bot +from bot.utils.leaderboard import add_points + +RPS_WIN_POINTS = 2 +RPS_GAME_NAME = "rps" CHOICES = ["rock", "paper", "scissors"] SHORT_CHOICES = ["r", "p", "s"] @@ -30,6 +34,9 @@ class RPS(commands.Cog): """Rock Paper Scissors. The Classic Game!""" + def __init__(self, bot: Bot): + self.bot = bot + @commands.command(case_insensitive=True) async def rps(self, ctx: commands.Context, move: str) -> None: """Play the classic game of Rock Paper Scissors with your own sir-lancebot!""" @@ -47,7 +54,8 @@ async def rps(self, ctx: commands.Context, move: str) -> None: message_string = f"{player_mention} You and Sir Lancebot played {bot_move}, it's a tie." await ctx.send(message_string) elif player_result == 1: - await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} won!") + _, earned = await add_points(self.bot, ctx.author.id, RPS_WIN_POINTS, RPS_GAME_NAME) + await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} won! (+{earned} pts)") else: await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} lost!") diff --git a/bot/exts/fun/snakes/_snakes_cog.py b/bot/exts/fun/snakes/_snakes_cog.py index 3f0a0764b9..7baf1b77e0 100644 --- a/bot/exts/fun/snakes/_snakes_cog.py +++ b/bot/exts/fun/snakes/_snakes_cog.py @@ -22,6 +22,10 @@ from bot.exts.fun.snakes import _utils as utils from bot.exts.fun.snakes._converter import Snake from bot.utils.decorators import locked +from bot.utils.leaderboard import add_points + +SNAKE_QUIZ_WIN_POINTS = 10 +SNAKE_QUIZ_GAME_NAME = "snakes_quiz" log = get_logger(__name__) @@ -406,7 +410,15 @@ async def _get_snake_name(self) -> dict[str, str]: """Gets a random snake name.""" return random.choice(self.snake_names) - async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: dict[str, str]) -> None: + async def _validate_answer( + self, + ctx: Context, + message: Message, + answer: str, + options: dict[str, str], + *, + award_points: int, + ) -> None: """Validate the answer using a reaction event loop.""" def predicate(reaction: Reaction, user: Member) -> bool: """Test if the the answer is valid and can be evaluated.""" @@ -428,7 +440,11 @@ def predicate(reaction: Reaction, user: Member) -> bool: return if str(reaction.emoji) == ANSWERS_EMOJI[answer]: - await ctx.send(f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**.") + _, earned = await add_points(self.bot, ctx.author.id, award_points, SNAKE_QUIZ_GAME_NAME) + await ctx.send( + f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**. " + f"(+{earned} pts)" + ) else: await ctx.send( f"{random.choice(INCORRECT_GUESS)} The correct answer was **{options[answer]}**." @@ -845,7 +861,7 @@ async def quiz_command(self, ctx: Context) -> None: ) quiz = await ctx.send(embed=embed) - await self._validate_answer(ctx, quiz, answer, options) + await self._validate_answer(ctx, quiz, answer, options, award_points=SNAKE_QUIZ_WIN_POINTS) @snakes_group.command(name="name", aliases=("name_gen",)) async def name_command(self, ctx: Context, *, name: str | None = None) -> None: diff --git a/bot/exts/fun/snakes/_utils.py b/bot/exts/fun/snakes/_utils.py index 77957187e7..aaf60fcafd 100644 --- a/bot/exts/fun/snakes/_utils.py +++ b/bot/exts/fun/snakes/_utils.py @@ -12,6 +12,10 @@ from pydis_core.utils.logging import get_logger from bot.constants import Emojis, MODERATION_ROLES +from bot.utils.leaderboard import add_points + +SNAKES_AND_LADDERS_WIN_POINTS = 15 +SNAKES_AND_LADDERS_GAME_NAME = "snakes_and_ladders" SNAKE_RESOURCES = Path("bot/resources/fun/snakes").absolute() @@ -684,7 +688,13 @@ async def _complete_round(self) -> None: return # announce winner and exit - await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:") + _, earned = await add_points( + self.ctx.bot, winner.id, SNAKES_AND_LADDERS_WIN_POINTS, SNAKES_AND_LADDERS_GAME_NAME + ) + await self.channel.send( + f"**Snakes and Ladders**: {winner.mention} has won the game! :tada: " + f"(+{earned} pts)" + ) self._destruct() def _check_winner(self) -> User | Member: diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py index f249c0729d..23adcd4994 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -6,8 +6,12 @@ from bot.bot import Bot from bot.constants import Emojis +from bot.utils.leaderboard import add_points from bot.utils.pagination import LinePaginator +TIC_TAC_TOE_WIN_POINTS = 10 +TIC_TAC_TOE_GAME_NAME = "tic_tac_toe" + CONFIRMATION_MESSAGE = ( "{opponent}, {requester} wants to play Tic-Tac-Toe against you." f"\nReact to this message with {Emojis.confirmation} to accept or with {Emojis.decline} to decline." @@ -219,9 +223,19 @@ async def play(self) -> None: 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:" - ) + + # Only award points to real users (not the AI/bot) + if isinstance(self.current, Player): + _, earned = await add_points( + self.ctx.bot, self.current.user.id, TIC_TAC_TOE_WIN_POINTS, TIC_TAC_TOE_GAME_NAME + ) + await self.ctx.send( + f":tada: {self.current} won this game! :tada: (+{earned} pts)" + ) + else: + 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 diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index d624e0fe1b..a54946b6cb 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -17,10 +17,12 @@ from bot.bot import Bot from bot.constants import Client, Colours, MODERATION_ROLES, NEGATIVE_REPLIES +from bot.utils.leaderboard import add_points logger = get_logger(__name__) DEFAULT_QUESTION_LIMIT = 7 +TRIVIA_QUIZ_GAME_NAME = "quiz" STANDARD_VARIATION_TOLERANCE = 88 DYNAMICALLY_GEN_VARIATION_TOLERANCE = 97 @@ -479,6 +481,7 @@ def contains_correct_answer(m: discord.Message) -> bool: break points = 100 - 25 * hint_no + leaderboard_points = max(10, 12 - hint_no) if msg.author in self.game_player_scores[ctx.channel.id]: self.game_player_scores[ctx.channel.id][msg.author] += points else: @@ -491,8 +494,11 @@ def contains_correct_answer(m: discord.Message) -> bool: self.player_scores[msg.author] = points hint_no = 0 - - await ctx.send(f"{msg.author.mention} got the correct answer :tada: {points} points!") + _, earned = await add_points(self.bot, msg.author.id, leaderboard_points, TRIVIA_QUIZ_GAME_NAME) + await ctx.send( + f"{msg.author.mention} got the correct answer :tada: " + f"{points} points! (+{earned} pts)" + ) await self.send_answer( ctx.channel, diff --git a/bot/exts/holidays/easter/easter_riddle.py b/bot/exts/holidays/easter/easter_riddle.py index a725ccd191..97ed19ce79 100644 --- a/bot/exts/holidays/easter/easter_riddle.py +++ b/bot/exts/holidays/easter/easter_riddle.py @@ -8,6 +8,10 @@ from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES +from bot.utils.leaderboard import add_points + +EASTER_RIDDLE_WIN_POINTS = 10 +EASTER_RIDDLE_GAME_NAME = "easter_riddle" log = get_logger(__name__) @@ -68,7 +72,7 @@ async def riddle(self, ctx: commands.Context) -> None: and m.content.lower() == correct.lower(), timeout=TIMELIMIT, ) - winner = response.author.mention + winner = response.author break except TimeoutError: hint_number += 1 @@ -82,8 +86,9 @@ async def riddle(self, ctx: commands.Context) -> None: break await ctx.send(embed=hint_embed) - if winner: - content = f"Well done {winner} for getting it right!" + if winner is not None: + new_total, earned = await add_points(self.bot, winner.id, EASTER_RIDDLE_WIN_POINTS, EASTER_RIDDLE_GAME_NAME) + content = f"Well done {winner.mention} for getting it right! (+{earned} pts)" else: content = "Nobody got it right..." diff --git a/bot/exts/holidays/easter/egghead_quiz.py b/bot/exts/holidays/easter/egghead_quiz.py index a876ae8b46..cce098f53c 100644 --- a/bot/exts/holidays/easter/egghead_quiz.py +++ b/bot/exts/holidays/easter/egghead_quiz.py @@ -9,7 +9,10 @@ from bot.bot import Bot from bot.constants import Colours +from bot.utils.leaderboard import add_points +EGGQUIZ_WIN_POINTS = 10 +EGGQUIZ_GAME_NAME = "eggquiz" log = get_logger(__name__) EGGHEAD_QUESTIONS = loads(Path("bot/resources/holidays/easter/egghead_questions.json").read_text("utf8")) @@ -103,11 +106,22 @@ async def eggquiz(self, ctx: commands.Context) -> None: # with the correct answer, so stop looping over reactions. break - mentions = " ".join([ - u.mention for u in users if not u.bot - ]) + winners = tuple(u for u in users if not u.bot) - content = f"Well done {mentions} for getting it correct!" if mentions else "Nobody got it right..." + points_earned = {} + for u in winners: + _, earned = await add_points(ctx.bot, u.id, EGGQUIZ_WIN_POINTS, EGGQUIZ_GAME_NAME) + points_earned[u.id] = earned + + mentions = " ".join(u.mention for u in winners) + + if winners and len(set(points_earned.values())) == 1: + pts = next(iter(points_earned.values())) + content = f"Well done {mentions} for getting it correct! (+{pts} pts)" + elif winners: + content = f"Well done {mentions} for getting it correct!" + else: + content = "Nobody got it right..." a_embed = discord.Embed( title=f"The correct answer was {correct}!", diff --git a/bot/resources/fun/duck-coin.png b/bot/resources/fun/duck-coin.png new file mode 100644 index 0000000000..3c7140bf37 Binary files /dev/null and b/bot/resources/fun/duck-coin.png differ diff --git a/bot/utils/leaderboard.py b/bot/utils/leaderboard.py new file mode 100644 index 0000000000..15ea203eb9 --- /dev/null +++ b/bot/utils/leaderboard.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import operator +from typing import TYPE_CHECKING + +from async_rediscache import RedisCache +from pydis_core.utils.logging import get_logger + +from bot.utils.quote import seconds_until_midnight_utc + +if TYPE_CHECKING: + from bot.bot import Bot + +log = get_logger(__name__) + +# Maximum points a user can earn per game per day. +DAILY_POINT_CAP = 100 + +# Prefix for daily cap keys stored directly in Redis (not via RedisCache). +_DAILY_KEY_PREFIX = "leaderboard:daily" + +#Global points cache, set by the Leaderboard cog on load. +POINTS_CACHE: RedisCache | None = None + + +def _daily_key(user_id: int, game_name: str) -> str: + """Build a namespaced Redis key for daily point tracking.""" + return f"{_DAILY_KEY_PREFIX}:{user_id}:{game_name}" + + +async def add_points(bot: Bot, user_id: int, points: int, game_name: str) -> tuple[int, int]: + """ + Add points to a user's global leaderboard score. + + Points are clamped by the daily cap per game ("DAILY_POINT_CAP"). + Daily entries expire automatically at UTC midnight via Redis TTL. + + Returns a tuple of (new_total_score, points_actually_earned). + Returns (0, 0) if the cog is not loaded. + """ + if points <= 0: + total = await get_user_points(bot, user_id) + return (total, 0) + + if bot.get_cog("Leaderboard") is None: + return (0, 0) + + redis = bot.redis_session.client + daily_key = _daily_key(user_id, game_name) + + # enforce daily cap + earned_today = await redis.get(daily_key) + earned_today = int(earned_today) if earned_today else 0 + + remaining = DAILY_POINT_CAP - earned_today + if remaining <= 0: + log.trace(f"User {user_id} hit daily cap for {game_name}, skipping.") + total = await get_user_points(bot, user_id) + return (total, 0) + + # clamp to remaining daily allowance + points_earned = min(points, remaining) + + ttl = seconds_until_midnight_utc() + await redis.set(daily_key, earned_today + points_earned, ex=ttl) + + # update persistent global total + await POINTS_CACHE.increment(user_id, points_earned) + + new_total = int(await POINTS_CACHE.get(user_id)) + return (new_total, points_earned) + + +async def remove_points(bot: Bot, user_id: int, points: int) -> int: + """ + Remove points from a user's global leaderboard score. + + Score will not go below 0. Returns the user's new total score, + or 0 if the cog is not loaded. + """ + if points <= 0 or bot.get_cog("Leaderboard") is None or POINTS_CACHE is None: + return 0 + + current = await POINTS_CACHE.get(user_id) + if not current: + return 0 + + current = int(current) + to_remove = min(points, current) + await POINTS_CACHE.decrement(user_id, to_remove) + + return current - to_remove + + +async def get_leaderboard(bot: Bot) -> list[tuple[int, int]]: + """ + Get all players from the global leaderboard. + + Returns a list of (user_id, score) tuples sorted by score descending. + """ + if bot.get_cog("Leaderboard") is None or POINTS_CACHE is None: + return [] + + records = await POINTS_CACHE.items() + + return sorted( + ((int(user_id), int(score)) for user_id, score in records if int(score) > 0), + key=operator.itemgetter(1), + reverse=True, + ) + + +async def get_daily_leaderboard(bot: Bot) -> list[tuple[int, int]]: + """ + Get today's leaderboard by scanning daily Redis TTL keys. + + Returns a list of (user_id, total_daily_score) tuples sorted in descending order. + """ + if bot.get_cog("Leaderboard") is None: + return [] + + redis = bot.redis_session.client + today_scores: dict[int, int] = {} + + async for key in redis.scan_iter(match=f"{_DAILY_KEY_PREFIX}:*"): + parts = key.split(":") + if len(parts) != 4: + continue + user_id = int(parts[2]) + points = int(await redis.get(key) or 0) + today_scores[user_id] = today_scores.get(user_id, 0) + points + + return sorted( + ((uid, score) for uid, score in today_scores.items() if score > 0), + key=lambda x: x[1], + reverse=True, + ) + + +async def get_user_rank( + bot: Bot, + user_id: int, + leaderboard: list[tuple[int, int]] | None = None, +) -> int | None: + """ + Get a user's rank on the global leaderboard, or None if unranked. + + Users with the same score share a rank. + """ + if leaderboard is None: + leaderboard = await get_leaderboard(bot) + + prev_score = None + rank = 0 + + for position, (uid, score) in enumerate(leaderboard, start=1): + if score != prev_score: + rank = position + prev_score = score + + if uid == user_id: + return rank + + return None + + +async def get_user_points(bot: Bot, user_id: int) -> int: + """Get a specific user's total points.""" + if bot.get_cog("Leaderboard") is None or POINTS_CACHE is None: + return 0 + + score = await POINTS_CACHE.get(user_id) + return int(score) if score else 0