From 10551a9d1dc3ea595f21c40c6e2f7a4bdb641690 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Mon, 23 Feb 2026 13:51:07 +0100 Subject: [PATCH 01/31] Add: Global leaderboard core infrastructure (#627) - Implements core utility functions for leaderboard - Uses Redis cache for persistency - Global leaderboard, daily global leaderboard, a specific user's points & admin clear command - Implements the coin flip game for testing the leaderboard - Uses the duck coin image shown in the issue --- bot/exts/fun/coinflip.py | 11 +- bot/exts/fun/leaderboard.py | 197 ++++++++++++++++++++++++++++++++ bot/resources/fun/duck-coin.png | Bin 0 -> 89695 bytes bot/utils/leaderboard.py | 161 ++++++++++++++++++++++++++ 4 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 bot/exts/fun/leaderboard.py create mode 100644 bot/resources/fun/duck-coin.png create mode 100644 bot/utils/leaderboard.py diff --git a/bot/exts/fun/coinflip.py b/bot/exts/fun/coinflip.py index b7dee44dd1..59957ecc18 100644 --- a/bot/exts/fun/coinflip.py +++ b/bot/exts/fun/coinflip.py @@ -4,6 +4,9 @@ from bot.bot import Bot from bot.constants import Emojis +from bot.utils.leaderboard import add_points + +COINFLIP_WIN_POINTS = 2 class CoinSide(commands.Converter): @@ -27,6 +30,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 +48,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}" + message += f"You guessed correctly! {Emojis.lemon_hyperpleased} (+{COINFLIP_WIN_POINTS} pts)" + await add_points(self.bot, ctx.author.id, COINFLIP_WIN_POINTS, "coinflip") else: message += f"You guessed incorrectly. {Emojis.lemon_pensive}" await ctx.send(message) @@ -50,4 +57,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/leaderboard.py b/bot/exts/fun/leaderboard.py new file mode 100644 index 0000000000..71bfe20da9 --- /dev/null +++ b/bot/exts/fun/leaderboard.py @@ -0,0 +1,197 @@ +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 get_daily_leaderboard, get_leaderboard, get_user_points, get_user_rank +from bot.utils.pagination import LinePaginator + +DUCK_COIN_THUMBNAIL = ( + "https://media.discordapp.net/attachments/1137013167340404912/1475115932224585922/" + "duck-coin.png?ex=699c5044&is=699afec4&hm=fecd34c81739c468e380a292dec6db3edb01bb9b5eea37752c67656987554655" + "&=&format=webp&quality=lossless&width=1518&height=1646" +) + +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 = [] + for rank, (user_id, score) in enumerate(records): + if rank < len(MEDALS): + prefix = MEDALS[rank] + else: + prefix = f"**#{rank + 1}**" + 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.""" + from bot.utils.leaderboard import _get_points_cache + + points_cache = await _get_points_cache() + 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 + + @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) + 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.""" + score = await get_user_points(self.bot, ctx.author.id) + rank = await get_user_rank(self.bot, ctx.author.id) + + description = f"{ctx.author.mention}: **{score}** pts" + if rank: + description += f" (Rank #{rank})" + + embed = discord.Embed( + colour=Colours.blue, + title="Your Global Points", + description=description, + ) + await ctx.send(embed=embed) + + @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/resources/fun/duck-coin.png b/bot/resources/fun/duck-coin.png new file mode 100644 index 0000000000000000000000000000000000000000..3c7140bf376c24fc2ed1eccf9400f6c89a856f95 GIT binary patch literal 89695 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 literal 0 HcmV?d00001 diff --git a/bot/utils/leaderboard.py b/bot/utils/leaderboard.py new file mode 100644 index 0000000000..79d579c8c3 --- /dev/null +++ b/bot/utils/leaderboard.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +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" + + +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 _get_points_cache() -> RedisCache: + """Get the persistent points cache from the Leaderboard cog.""" + from bot.exts.fun.leaderboard import Leaderboard + return Leaderboard.points_cache + + +async def add_points(bot: Bot, user_id: int, points: int, game_name: str) -> 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 the user's new total score, or 0 if the cog is not loaded. + """ + if points <= 0: + return await get_user_points(bot, user_id) + + if bot.get_cog("Leaderboard") is None: + return 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.") + return await get_user_points(bot, user_id) + + # clamp to remaining daily allowance + points = min(points, remaining) + + ttl = seconds_until_midnight_utc() + await redis.set(daily_key, earned_today + points, ex=ttl) + + # update persistent global total + points_cache = await _get_points_cache() + if await points_cache.contains(user_id): + await points_cache.increment(user_id, points) + else: + await points_cache.set(user_id, points) + + return int(await points_cache.get(user_id)) + + +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: + return await get_user_points(bot, user_id) + + points_cache = await _get_points_cache() + + 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: + return [] + + points_cache = await _get_points_cache() + records = await points_cache.items() + + return sorted( + ((int(user_id), int(score)) for user_id, score in records if int(score) > 0), + key=lambda x: x[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 descending. + """ + 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) -> int | None: + """Get a user's rank on the global leaderboard, or None if unranked.""" + leaderboard = await get_leaderboard(bot) + for rank, (uid, _score) in enumerate(leaderboard, start=1): + 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: + return 0 + + points_cache = await _get_points_cache() + score = await points_cache.get(user_id) + return int(score) if score else 0 From 3c3a41021a009dfe7109c11d5d5fef78d2a93984 Mon Sep 17 00:00:00 2001 From: jenscancio Date: Fri, 27 Feb 2026 16:18:58 +0100 Subject: [PATCH 02/31] Integrate games to update leaderboard and normalize points for each game (python-discord#627) --- bot/exts/fun/battleship.py | 14 ++++++++++-- bot/exts/fun/connect_four.py | 16 +++++++++++--- bot/exts/fun/minesweeper.py | 26 +++++++++++++++++++---- bot/exts/fun/snakes/_snakes_cog.py | 25 +++++++++++++++++++--- bot/exts/fun/snakes/_utils.py | 9 +++++++- bot/exts/fun/tic_tac_toe.py | 15 ++++++++++--- bot/exts/fun/trivia_quiz.py | 6 ++++-- bot/exts/holidays/easter/easter_riddle.py | 10 +++++++-- bot/exts/holidays/easter/egghead_quiz.py | 17 +++++++++++---- 9 files changed, 114 insertions(+), 24 deletions(-) diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py index f711074cbe..05f4fabc47 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -9,6 +9,10 @@ from bot.bot import Bot from bot.constants import Colours, Emojis +from bot.utils.leaderboard import add_points + +BATTLESHIP_WIN_POINTS = 6 + log = get_logger(__name__) @@ -150,7 +154,7 @@ 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}") + await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention} (+{BATTLESHIP_WIN_POINTS} pts)") for player in (self.p1, self.p2): grid = self.format_grid(player, SHIP_EMOJIS) @@ -247,7 +251,9 @@ async def take_turn(self) -> Square | None: await self.next.user.send(f"{self.turn.user} took too long. Game over!") await self.public_channel.send( f"Game over! {self.turn.user.mention} timed out so {self.next.user.mention} wins!" + f"(+{BATTLESHIP_WIN_POINTS} pts)" ) + await add_points(self.bot, self.next.user.id, BATTLESHIP_WIN_POINTS, "battleship") self.gameover = True break else: @@ -255,7 +261,9 @@ async def take_turn(self) -> Square | None: await self.next.user.send(f"{self.turn.user} surrendered. Game over!") await self.public_channel.send( f"Game over! {self.turn.user.mention} surrendered to {self.next.user.mention}!" + f"(+{BATTLESHIP_WIN_POINTS} pts)" ) + await add_points(self.bot, self.next.user.id, BATTLESHIP_WIN_POINTS, "battleship") self.gameover = True break square = self.get_square(self.next.grid, self.match.string) @@ -274,10 +282,12 @@ 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!") + await self.turn.user.send(f"You win! (+{BATTLESHIP_WIN_POINTS} pts)") await self.next.user.send("You lose!") self.gameover = True + await add_points(self.bot, self.turn.user.id, BATTLESHIP_WIN_POINTS, "battleship") await self.game_over(winner=self.turn.user, loser=self.next.user) + async def start_game(self) -> None: """Begins the game.""" diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index 5e45c57659..c0dc080649 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -8,6 +8,9 @@ from bot.bot import Bot from bot.constants import Emojis +from bot.utils.leaderboard import add_points + +CONNECT_FOUR_WIN_POINTS = 5 NUMBERS = list(Emojis.number_emojis.values()) CROSS_EMOJI = Emojis.incident_unactioned @@ -75,11 +78,15 @@ 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}") + await self.channel.send(f"Game Over! {player1.mention} won against {player2.mention}"f"(+{CONNECT_FOUR_WIN_POINTS} pts)") + if isinstance(player1, Member): + await add_points(self.bot, player1.id, CONNECT_FOUR_WIN_POINTS, "connect_four") 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!") + await self.channel.send(f"{player1.mention} surrendered. {player2.mention} wins! Game over!"f"(+{CONNECT_FOUR_WIN_POINTS} pts)") + if isinstance(player2, Member): + await add_points(self.bot, player2.id, CONNECT_FOUR_WIN_POINTS, "connect_four") await self.print_grid() async def start_game(self) -> None: @@ -131,7 +138,10 @@ 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!") + await self.channel.send(f"{self.player_active.mention}, you took too long. Game over!" + f"{self.player_inactive.mention} wins! (+{CONNECT_FOUR_WIN_POINTS} pts)") + if isinstance(self.player_inactive, Member): + await add_points(self.bot, self.player_inactive.id, CONNECT_FOUR_WIN_POINTS, "connect_four") return None else: await message.delete() diff --git a/bot/exts/fun/minesweeper.py b/bot/exts/fun/minesweeper.py index 29cd8ee9ba..f2f01ea656 100644 --- a/bot/exts/fun/minesweeper.py +++ b/bot/exts/fun/minesweeper.py @@ -10,6 +10,9 @@ 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 = 6 MESSAGE_MAPPING = { 0: ":stop_button:", @@ -52,6 +55,17 @@ 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: + if bomb_chance <= 0.15: + return 4 + if bomb_chance <= 0.20: + return 6 + if bomb_chance <= 0.25: + return 8 + return 10 @commands.group(name="minesweeper", aliases=("ms",), invoke_without_command=True) async def minesweeper_group(self, ctx: commands.Context) -> None: @@ -123,7 +137,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 +197,12 @@ 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, 6) + await add_points(self.bot, ctx.author.id, points, "minesweeper") + await ctx.author.send(f":tada: You won! :tada: (+{points} 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 +261,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 +279,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/snakes/_snakes_cog.py b/bot/exts/fun/snakes/_snakes_cog.py index 3f0a0764b9..3d5c59e1c3 100644 --- a/bot/exts/fun/snakes/_snakes_cog.py +++ b/bot/exts/fun/snakes/_snakes_cog.py @@ -22,6 +22,9 @@ 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 = 2 log = get_logger(__name__) @@ -406,7 +409,16 @@ 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 = None, + game: str = "snakes_quiz", + ) -> 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,14 @@ 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]}**.") + if award_points is not None: + await add_points(self.bot, ctx.author.id, award_points, game) + await ctx.send( + f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**. " + f"(+{award_points} pts)" + ) + else: + 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]}**." @@ -845,7 +864,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, game="snakes_quiz") @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..86c0b30827 100644 --- a/bot/exts/fun/snakes/_utils.py +++ b/bot/exts/fun/snakes/_utils.py @@ -12,6 +12,9 @@ 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 = 8 SNAKE_RESOURCES = Path("bot/resources/fun/snakes").absolute() @@ -684,7 +687,11 @@ 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:") + await add_points(self.ctx.bot, winner.id, SNAKES_AND_LADDERS_WIN_POINTS, "snakes_and_ladders") + await self.channel.send( + f"**Snakes and Ladders**: {winner.mention} has won the game! :tada: " + f"(+{SNAKES_AND_LADDERS_WIN_POINTS} 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..e85a44ab6f 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -7,6 +7,9 @@ from bot.bot import Bot from bot.constants import Emojis from bot.utils.pagination import LinePaginator +from bot.utils.leaderboard import add_points + +TIC_TAC_TOE_WIN_POINTS = 3 CONFIRMATION_MESSAGE = ( "{opponent}, {requester} wants to play Tic-Tac-Toe against you." @@ -219,9 +222,15 @@ 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): + await add_points(self.ctx.bot, self.current.user.id, TIC_TAC_TOE_WIN_POINTS, "tic_tac_toe") + await self.ctx.send(f":tada: {self.current} won this game! :tada: (+{TIC_TAC_TOE_WIN_POINTS} 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..735ad5f6ea 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -17,6 +17,7 @@ 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__) @@ -479,6 +480,7 @@ def contains_correct_answer(m: discord.Message) -> bool: break points = 100 - 25 * hint_no + leaderboard_points = max(1, 3 - 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 +493,8 @@ 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!") + await add_points(self.bot, msg.author.id, leaderboard_points, "quiz") + await ctx.send(f"{msg.author.mention} got the correct answer :tada: {leaderboard_points} points!") 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..7c866a848b 100644 --- a/bot/exts/holidays/easter/easter_riddle.py +++ b/bot/exts/holidays/easter/easter_riddle.py @@ -8,6 +8,9 @@ from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES +from bot.utils.leaderboard import add_points + +EASTER_RIDDLE_WIN_POINTS = 3 log = get_logger(__name__) @@ -59,6 +62,7 @@ async def riddle(self, ctx: commands.Context) -> None: await ctx.send(embed=riddle_embed) hint_number = 0 winner = None + winner_id = None while hint_number < 3: try: response = await self.bot.wait_for( @@ -69,6 +73,7 @@ async def riddle(self, ctx: commands.Context) -> None: timeout=TIMELIMIT, ) winner = response.author.mention + winner_id = response.author.id break except TimeoutError: hint_number += 1 @@ -82,8 +87,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_id is not None: + await add_points(self.bot, winner_id, EASTER_RIDDLE_WIN_POINTS, "easter_riddle") + content = f"Well done {winner} for getting it right! (+{EASTER_RIDDLE_WIN_POINTS} 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..5653bb445f 100644 --- a/bot/exts/holidays/easter/egghead_quiz.py +++ b/bot/exts/holidays/easter/egghead_quiz.py @@ -9,7 +9,9 @@ from bot.bot import Bot from bot.constants import Colours +from bot.utils.leaderboard import add_points +EGGQUIZ_WIN_POINTS = 3 log = get_logger(__name__) EGGHEAD_QUESTIONS = loads(Path("bot/resources/holidays/easter/egghead_questions.json").read_text("utf8")) @@ -103,11 +105,18 @@ 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 = [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..." + for u in winners: + await add_points(ctx.bot, u.id, EGGQUIZ_WIN_POINTS, "eggquiz") + + mentions = " ".join(u.mention for u in winners) + + content = ( + f"Well done {mentions} for getting it correct! (+{EGGQUIZ_WIN_POINTS} pts)" + if mentions + else "Nobody got it right..." + ) a_embed = discord.Embed( title=f"The correct answer was {correct}!", From 727d1e595acb8fdf367ec42e37d54868ba546f63 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Sat, 28 Feb 2026 16:18:42 +0100 Subject: [PATCH 03/31] Fix: missing space issues in messages --- bot/exts/fun/battleship.py | 4 ++-- bot/exts/fun/connect_four.py | 4 ++-- bot/exts/fun/minesweeper.py | 6 ++++-- bot/exts/fun/trivia_quiz.py | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py index 05f4fabc47..1d5c8dc9e2 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -250,7 +250,7 @@ async def take_turn(self) -> Square | None: 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!") 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"(+{BATTLESHIP_WIN_POINTS} pts)" ) await add_points(self.bot, self.next.user.id, BATTLESHIP_WIN_POINTS, "battleship") @@ -260,7 +260,7 @@ async def take_turn(self) -> Square | None: if self.surrender: await self.next.user.send(f"{self.turn.user} surrendered. Game over!") 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"(+{BATTLESHIP_WIN_POINTS} pts)" ) await add_points(self.bot, self.next.user.id, BATTLESHIP_WIN_POINTS, "battleship") diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index c0dc080649..01e27dfb74 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -78,13 +78,13 @@ 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}"f"(+{CONNECT_FOUR_WIN_POINTS} pts)") + await self.channel.send(f"Game Over! {player1.mention} won against {player2.mention} (+{CONNECT_FOUR_WIN_POINTS} pts)") if isinstance(player1, Member): await add_points(self.bot, player1.id, CONNECT_FOUR_WIN_POINTS, "connect_four") 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"{player1.mention} surrendered. {player2.mention} wins! Game over!"f"(+{CONNECT_FOUR_WIN_POINTS} pts)") + await self.channel.send(f"{player1.mention} surrendered. {player2.mention} wins! Game over! (+{CONNECT_FOUR_WIN_POINTS} pts)") if isinstance(player2, Member): await add_points(self.bot, player2.id, CONNECT_FOUR_WIN_POINTS, "connect_four") await self.print_grid() diff --git a/bot/exts/fun/minesweeper.py b/bot/exts/fun/minesweeper.py index f2f01ea656..d1f25cd6b5 100644 --- a/bot/exts/fun/minesweeper.py +++ b/bot/exts/fun/minesweeper.py @@ -59,6 +59,7 @@ def __init__(self, bot: Bot): @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 4 if bomb_chance <= 0.20: @@ -197,11 +198,12 @@ 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] - points = self.points_by_user.get(ctx.author.id, 6) + points = self.points_by_user.get(ctx.author.id, 6) await add_points(self.bot, ctx.author.id, points, "minesweeper") await ctx.author.send(f":tada: You won! :tada: (+{points} pts)") if game.activated_on_server: - await game.chat_msg.channel.send(f":tada: {ctx.author.mention} just won Minesweeper! :tada: (+{points} pts)" + 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.""" diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index 735ad5f6ea..0c26f40ca8 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -494,7 +494,7 @@ def contains_correct_answer(m: discord.Message) -> bool: hint_no = 0 await add_points(self.bot, msg.author.id, leaderboard_points, "quiz") - await ctx.send(f"{msg.author.mention} got the correct answer :tada: {leaderboard_points} points!") + await ctx.send(f"{msg.author.mention} got the correct answer :tada: {points} points! (+{leaderboard_points} global pts)") await self.send_answer( ctx.channel, From 72f7ac183919375178d4b001197dae0ec0c44ca8 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Sat, 28 Feb 2026 16:27:28 +0100 Subject: [PATCH 04/31] Add: remaining games to update global leaderboard on win --- bot/exts/fun/anagram.py | 9 ++++++++- bot/exts/fun/duck_game.py | 22 +++++++++++++++++++++- bot/exts/fun/hangman.py | 6 +++++- bot/exts/fun/rps.py | 9 ++++++++- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py index 4e23c0798e..3cb7656a48 100644 --- a/bot/exts/fun/anagram.py +++ b/bot/exts/fun/anagram.py @@ -9,6 +9,9 @@ from bot.bot import Bot from bot.constants import Colours +from bot.utils.leaderboard import add_points + +ANAGRAM_WIN_POINTS = 3 log = get_logger(__name__) @@ -32,11 +35,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 +82,9 @@ 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!" + for winner_id in game.winner_ids: + await add_points(self.bot, winner_id, ANAGRAM_WIN_POINTS, "anagram") + content = f"Well done {win_list} for getting it right! (+{ANAGRAM_WIN_POINTS} pts)" else: content = "Nobody got it right." diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index fbdc9ea2ee..f92bfd1dc8 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -13,6 +13,11 @@ 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 = 10 +DUCK_GAME_SECOND_PLACE_POINTS = 6 +DUCK_GAME_THIRD_PLACE_POINTS = 3 DECK = list(product(*[(0, 1, 2)]*4)) @@ -297,8 +302,23 @@ async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_messa key=lambda item: item[1], reverse=True, ) + + # Award leaderboard points to top 3 players + point_awards = [ + DUCK_GAME_FIRST_PLACE_POINTS, + DUCK_GAME_SECOND_PLACE_POINTS, + DUCK_GAME_THIRD_PLACE_POINTS + ] + for rank, (member, score) in enumerate(scores[:3]): + if score > 0: + await add_points(self.bot, member.id, point_awards[rank], "duck_game") + 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 < 3 and score > 0: + scoreboard += f"{member.display_name}: {score} (+{point_awards[rank]} global 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..64a6be3ce4 100644 --- a/bot/exts/fun/hangman.py +++ b/bot/exts/fun/hangman.py @@ -6,6 +6,9 @@ from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES +from bot.utils.leaderboard import add_points + +HANGMAN_WIN_POINTS = 4 # 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 +170,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)) + await add_points(self.bot, ctx.author.id, HANGMAN_WIN_POINTS, "hangman") win_embed = Embed( title="You won!", - description=f"The word was `{word}`.", + description=f"The word was `{word}`. (+{HANGMAN_WIN_POINTS} pts)", color=Colours.grass_green ) await ctx.send(embed=win_embed) diff --git a/bot/exts/fun/rps.py b/bot/exts/fun/rps.py index 501298355d..db05c1afe6 100644 --- a/bot/exts/fun/rps.py +++ b/bot/exts/fun/rps.py @@ -3,6 +3,9 @@ from discord.ext import commands from bot.bot import Bot +from bot.utils.leaderboard import add_points + +RPS_WIN_POINTS = 2 CHOICES = ["rock", "paper", "scissors"] SHORT_CHOICES = ["r", "p", "s"] @@ -30,6 +33,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 +53,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!") + await add_points(self.bot, ctx.author.id, RPS_WIN_POINTS, "rps") + await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} won! (+{RPS_WIN_POINTS} pts)") else: await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} lost!") From d884d43f841b799b5d4640020b04fb5ceb844806 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Sat, 28 Feb 2026 16:45:33 +0100 Subject: [PATCH 05/31] Fix: a more fair point system with coinflip = 2 as baseline --- bot/exts/fun/anagram.py | 2 +- bot/exts/fun/battleship.py | 2 +- bot/exts/fun/connect_four.py | 2 +- bot/exts/fun/duck_game.py | 6 +++--- bot/exts/fun/hangman.py | 2 +- bot/exts/fun/minesweeper.py | 10 +++++----- bot/exts/fun/snakes/_snakes_cog.py | 2 +- bot/exts/fun/snakes/_utils.py | 2 +- bot/exts/fun/tic_tac_toe.py | 2 +- bot/exts/fun/trivia_quiz.py | 2 +- bot/exts/holidays/easter/easter_riddle.py | 2 +- bot/exts/holidays/easter/egghead_quiz.py | 2 +- 12 files changed, 18 insertions(+), 18 deletions(-) diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py index 3cb7656a48..4bb6555fca 100644 --- a/bot/exts/fun/anagram.py +++ b/bot/exts/fun/anagram.py @@ -11,7 +11,7 @@ from bot.constants import Colours from bot.utils.leaderboard import add_points -ANAGRAM_WIN_POINTS = 3 +ANAGRAM_WIN_POINTS = 10 log = get_logger(__name__) diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py index 1d5c8dc9e2..0367c2843f 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -11,7 +11,7 @@ from bot.constants import Colours, Emojis from bot.utils.leaderboard import add_points -BATTLESHIP_WIN_POINTS = 6 +BATTLESHIP_WIN_POINTS = 30 log = get_logger(__name__) diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index 01e27dfb74..24dfeef397 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -10,7 +10,7 @@ from bot.constants import Emojis from bot.utils.leaderboard import add_points -CONNECT_FOUR_WIN_POINTS = 5 +CONNECT_FOUR_WIN_POINTS = 15 NUMBERS = list(Emojis.number_emojis.values()) CROSS_EMOJI = Emojis.incident_unactioned diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index f92bfd1dc8..405834fc50 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -15,9 +15,9 @@ from bot.utils.decorators import with_role from bot.utils.leaderboard import add_points -DUCK_GAME_FIRST_PLACE_POINTS = 10 -DUCK_GAME_SECOND_PLACE_POINTS = 6 -DUCK_GAME_THIRD_PLACE_POINTS = 3 +DUCK_GAME_FIRST_PLACE_POINTS = 30 +DUCK_GAME_SECOND_PLACE_POINTS = 20 +DUCK_GAME_THIRD_PLACE_POINTS = 10 DECK = list(product(*[(0, 1, 2)]*4)) diff --git a/bot/exts/fun/hangman.py b/bot/exts/fun/hangman.py index 64a6be3ce4..f35a32dfa9 100644 --- a/bot/exts/fun/hangman.py +++ b/bot/exts/fun/hangman.py @@ -8,7 +8,7 @@ from bot.constants import Colours, NEGATIVE_REPLIES from bot.utils.leaderboard import add_points -HANGMAN_WIN_POINTS = 4 +HANGMAN_WIN_POINTS = 15 # Defining all words in the list of words as a global variable ALL_WORDS = Path("bot/resources/fun/hangman_words.txt").read_text().splitlines() diff --git a/bot/exts/fun/minesweeper.py b/bot/exts/fun/minesweeper.py index d1f25cd6b5..b76c9ed5d7 100644 --- a/bot/exts/fun/minesweeper.py +++ b/bot/exts/fun/minesweeper.py @@ -12,7 +12,7 @@ from bot.utils.exceptions import UserNotPlayingError from bot.utils.leaderboard import add_points -MINESWEEPER_WIN_POINTS = 6 +MINESWEEPER_WIN_POINTS = 15 MESSAGE_MAPPING = { 0: ":stop_button:", @@ -61,12 +61,12 @@ def __init__(self, bot: Bot): 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 4 + return 15 if bomb_chance <= 0.20: - return 6 + return 17 if bomb_chance <= 0.25: - return 8 - return 10 + return 20 + return 15 @commands.group(name="minesweeper", aliases=("ms",), invoke_without_command=True) async def minesweeper_group(self, ctx: commands.Context) -> None: diff --git a/bot/exts/fun/snakes/_snakes_cog.py b/bot/exts/fun/snakes/_snakes_cog.py index 3d5c59e1c3..eee446a726 100644 --- a/bot/exts/fun/snakes/_snakes_cog.py +++ b/bot/exts/fun/snakes/_snakes_cog.py @@ -24,7 +24,7 @@ from bot.utils.decorators import locked from bot.utils.leaderboard import add_points -SNAKE_QUIZ_WIN_POINTS = 2 +SNAKE_QUIZ_WIN_POINTS = 10 log = get_logger(__name__) diff --git a/bot/exts/fun/snakes/_utils.py b/bot/exts/fun/snakes/_utils.py index 86c0b30827..2e4cbb6b33 100644 --- a/bot/exts/fun/snakes/_utils.py +++ b/bot/exts/fun/snakes/_utils.py @@ -14,7 +14,7 @@ from bot.constants import Emojis, MODERATION_ROLES from bot.utils.leaderboard import add_points -SNAKES_AND_LADDERS_WIN_POINTS = 8 +SNAKES_AND_LADDERS_WIN_POINTS = 15 SNAKE_RESOURCES = Path("bot/resources/fun/snakes").absolute() diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py index e85a44ab6f..f2b908f220 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -9,7 +9,7 @@ from bot.utils.pagination import LinePaginator from bot.utils.leaderboard import add_points -TIC_TAC_TOE_WIN_POINTS = 3 +TIC_TAC_TOE_WIN_POINTS = 10 CONFIRMATION_MESSAGE = ( "{opponent}, {requester} wants to play Tic-Tac-Toe against you." diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index 0c26f40ca8..6b285a232c 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -480,7 +480,7 @@ def contains_correct_answer(m: discord.Message) -> bool: break points = 100 - 25 * hint_no - leaderboard_points = max(1, 3 - 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: diff --git a/bot/exts/holidays/easter/easter_riddle.py b/bot/exts/holidays/easter/easter_riddle.py index 7c866a848b..da87b26af5 100644 --- a/bot/exts/holidays/easter/easter_riddle.py +++ b/bot/exts/holidays/easter/easter_riddle.py @@ -10,7 +10,7 @@ from bot.constants import Colours, NEGATIVE_REPLIES from bot.utils.leaderboard import add_points -EASTER_RIDDLE_WIN_POINTS = 3 +EASTER_RIDDLE_WIN_POINTS = 10 log = get_logger(__name__) diff --git a/bot/exts/holidays/easter/egghead_quiz.py b/bot/exts/holidays/easter/egghead_quiz.py index 5653bb445f..203310d4f3 100644 --- a/bot/exts/holidays/easter/egghead_quiz.py +++ b/bot/exts/holidays/easter/egghead_quiz.py @@ -11,7 +11,7 @@ from bot.constants import Colours from bot.utils.leaderboard import add_points -EGGQUIZ_WIN_POINTS = 3 +EGGQUIZ_WIN_POINTS = 10 log = get_logger(__name__) EGGHEAD_QUESTIONS = loads(Path("bot/resources/holidays/easter/egghead_questions.json").read_text("utf8")) From 36fb83725be10f360ff5f1e3f17d94363ec5b463 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Sat, 28 Feb 2026 18:24:49 +0100 Subject: [PATCH 06/31] Fix: correct github content link for duck coin --- bot/exts/fun/leaderboard.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/fun/leaderboard.py b/bot/exts/fun/leaderboard.py index 71bfe20da9..a1aab1bb3f 100644 --- a/bot/exts/fun/leaderboard.py +++ b/bot/exts/fun/leaderboard.py @@ -10,9 +10,7 @@ from bot.utils.pagination import LinePaginator DUCK_COIN_THUMBNAIL = ( - "https://media.discordapp.net/attachments/1137013167340404912/1475115932224585922/" - "duck-coin.png?ex=699c5044&is=699afec4&hm=fecd34c81739c468e380a292dec6db3edb01bb9b5eea37752c67656987554655" - "&=&format=webp&quality=lossless&width=1518&height=1646" + "https://raw.githubusercontent.com/python-discord/sir-lancebot/main/bot/resources/fun/duck-coin.png" ) MEDALS = ( From 5eaf692b9aff29ab8f137a1267f1180d1487c0d2 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Sat, 28 Feb 2026 18:36:41 +0100 Subject: [PATCH 07/31] Fix: trailing-whitespace --- bot/exts/fun/battleship.py | 2 +- bot/exts/fun/duck_game.py | 4 ++-- bot/exts/fun/minesweeper.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py index 0367c2843f..e6527df1f5 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -287,7 +287,7 @@ async def hit(self, square: Square, alert_messages: list[discord.Message]) -> No self.gameover = True await add_points(self.bot, self.turn.user.id, BATTLESHIP_WIN_POINTS, "battleship") await self.game_over(winner=self.turn.user, loser=self.next.user) - + async def start_game(self) -> None: """Begins the game.""" diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index 405834fc50..df6fc1cb30 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -302,7 +302,7 @@ async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_messa key=lambda item: item[1], reverse=True, ) - + # Award leaderboard points to top 3 players point_awards = [ DUCK_GAME_FIRST_PLACE_POINTS, @@ -312,7 +312,7 @@ async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_messa for rank, (member, score) in enumerate(scores[:3]): if score > 0: await add_points(self.bot, member.id, point_awards[rank], "duck_game") - + scoreboard = "Final scores:\n\n" for rank, (member, score) in enumerate(scores): if rank < 3 and score > 0: diff --git a/bot/exts/fun/minesweeper.py b/bot/exts/fun/minesweeper.py index b76c9ed5d7..d3b4fda64d 100644 --- a/bot/exts/fun/minesweeper.py +++ b/bot/exts/fun/minesweeper.py @@ -66,7 +66,7 @@ def points_for_bomb_chance(bomb_chance: float) -> int: return 17 if bomb_chance <= 0.25: return 20 - return 15 + return 15 @commands.group(name="minesweeper", aliases=("ms",), invoke_without_command=True) async def minesweeper_group(self, ctx: commands.Context) -> None: From d0e99d24cd8b49591b2ae4dee2c598c536d0fef3 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Sat, 28 Feb 2026 18:39:46 +0100 Subject: [PATCH 08/31] Fix: ruff check errors --- bot/exts/fun/battleship.py | 4 +++- bot/exts/fun/connect_four.py | 8 ++++++-- bot/exts/fun/tic_tac_toe.py | 2 +- bot/exts/fun/trivia_quiz.py | 5 ++++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py index e6527df1f5..4d3118fc29 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -154,7 +154,9 @@ 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} (+{BATTLESHIP_WIN_POINTS} pts)") + await self.public_channel.send( + f"Game Over! {winner.mention} won against {loser.mention} (+{BATTLESHIP_WIN_POINTS} pts)" + ) for player in (self.p1, self.p2): grid = self.format_grid(player, SHIP_EMOJIS) diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index 24dfeef397..98e7b23ed3 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -78,13 +78,17 @@ 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} (+{CONNECT_FOUR_WIN_POINTS} pts)") + await self.channel.send( + f"Game Over! {player1.mention} won against {player2.mention} (+{CONNECT_FOUR_WIN_POINTS} pts)" + ) if isinstance(player1, Member): await add_points(self.bot, player1.id, CONNECT_FOUR_WIN_POINTS, "connect_four") 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"{player1.mention} surrendered. {player2.mention} wins! Game over! (+{CONNECT_FOUR_WIN_POINTS} pts)") + await self.channel.send( + f"{player1.mention} surrendered. {player2.mention} wins! Game over! (+{CONNECT_FOUR_WIN_POINTS} pts)" + ) if isinstance(player2, Member): await add_points(self.bot, player2.id, CONNECT_FOUR_WIN_POINTS, "connect_four") await self.print_grid() diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py index f2b908f220..063f3fcdff 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -6,8 +6,8 @@ from bot.bot import Bot from bot.constants import Emojis -from bot.utils.pagination import LinePaginator from bot.utils.leaderboard import add_points +from bot.utils.pagination import LinePaginator TIC_TAC_TOE_WIN_POINTS = 10 diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index 6b285a232c..d30a1a2a76 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -494,7 +494,10 @@ def contains_correct_answer(m: discord.Message) -> bool: hint_no = 0 await add_points(self.bot, msg.author.id, leaderboard_points, "quiz") - await ctx.send(f"{msg.author.mention} got the correct answer :tada: {points} points! (+{leaderboard_points} global pts)") + await ctx.send( + f"{msg.author.mention} got the correct answer :tada: " + f"{points} points! (+{leaderboard_points} global pts)" + ) await self.send_answer( ctx.channel, From 1042d083670654c68a493a6610b35bdaf81a58cb Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Sun, 1 Mar 2026 14:12:20 +0100 Subject: [PATCH 09/31] Fix: add_points to return actual earned points Now if points are clamped due to daily limit, the real value is shown instead of the constants. Updates all 14 games to use this new value. --- bot/exts/fun/anagram.py | 11 +++++-- bot/exts/fun/battleship.py | 22 +++++++------- bot/exts/fun/coinflip.py | 4 +-- bot/exts/fun/connect_four.py | 37 ++++++++++++++++------- bot/exts/fun/duck_game.py | 8 +++-- bot/exts/fun/hangman.py | 4 +-- bot/exts/fun/minesweeper.py | 4 +-- bot/exts/fun/rps.py | 4 +-- bot/exts/fun/snakes/_snakes_cog.py | 4 +-- bot/exts/fun/snakes/_utils.py | 4 +-- bot/exts/fun/tic_tac_toe.py | 4 +-- bot/exts/fun/trivia_quiz.py | 4 +-- bot/exts/holidays/easter/easter_riddle.py | 4 +-- bot/exts/holidays/easter/egghead_quiz.py | 16 ++++++---- bot/utils/leaderboard.py | 24 +++++++++------ 15 files changed, 93 insertions(+), 61 deletions(-) diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py index 4bb6555fca..ab5327c023 100644 --- a/bot/exts/fun/anagram.py +++ b/bot/exts/fun/anagram.py @@ -82,9 +82,16 @@ async def anagram_command(self, ctx: commands.Context) -> None: if game.winners: win_list = ", ".join(game.winners) + points_earned = set() for winner_id in game.winner_ids: - await add_points(self.bot, winner_id, ANAGRAM_WIN_POINTS, "anagram") - content = f"Well done {win_list} for getting it right! (+{ANAGRAM_WIN_POINTS} pts)" + _, earned = await add_points(self.bot, winner_id, ANAGRAM_WIN_POINTS, "anagram") + 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 4d3118fc29..5db8d29b9f 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -154,13 +154,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} (+{BATTLESHIP_WIN_POINTS} pts)" - ) + _, earned = await add_points(self.bot, winner.id, BATTLESHIP_WIN_POINTS, "battleship") + 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: @@ -251,21 +251,21 @@ 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") await self.public_channel.send( f"Game over! {self.turn.user.mention} timed out so {self.next.user.mention} wins! " - f"(+{BATTLESHIP_WIN_POINTS} pts)" + f"(+{earned} pts)" ) - await add_points(self.bot, self.next.user.id, BATTLESHIP_WIN_POINTS, "battleship") 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") await self.public_channel.send( f"Game over! {self.turn.user.mention} surrendered to {self.next.user.mention}! " - f"(+{BATTLESHIP_WIN_POINTS} pts)" + f"(+{earned} pts)" ) - await add_points(self.bot, self.next.user.id, BATTLESHIP_WIN_POINTS, "battleship") self.gameover = True break square = self.get_square(self.next.grid, self.match.string) @@ -284,10 +284,10 @@ 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(f"You win! (+{BATTLESHIP_WIN_POINTS} pts)") + _, earned = await add_points(self.bot, self.turn.user.id, BATTLESHIP_WIN_POINTS, "battleship") + await self.turn.user.send(f"You win! (+{earned} pts)") await self.next.user.send("You lose!") self.gameover = True - await add_points(self.bot, self.turn.user.id, BATTLESHIP_WIN_POINTS, "battleship") await self.game_over(winner=self.turn.user, loser=self.next.user) diff --git a/bot/exts/fun/coinflip.py b/bot/exts/fun/coinflip.py index 59957ecc18..2a037dddac 100644 --- a/bot/exts/fun/coinflip.py +++ b/bot/exts/fun/coinflip.py @@ -48,8 +48,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} (+{COINFLIP_WIN_POINTS} pts)" - await add_points(self.bot, ctx.author.id, COINFLIP_WIN_POINTS, "coinflip") + _, earned = await add_points(self.bot, ctx.author.id, COINFLIP_WIN_POINTS, "coinflip") + message += f"You guessed correctly! {Emojis.lemon_hyperpleased} (+{earned} pts)" else: message += f"You guessed incorrectly. {Emojis.lemon_pensive}" await ctx.send(message) diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index 98e7b23ed3..44a965f564 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -78,19 +78,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} (+{CONNECT_FOUR_WIN_POINTS} pts)" - ) if isinstance(player1, Member): - await add_points(self.bot, player1.id, CONNECT_FOUR_WIN_POINTS, "connect_four") + _, earned = await add_points(self.bot, player1.id, CONNECT_FOUR_WIN_POINTS, "connect_four") + 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"{player1.mention} surrendered. {player2.mention} wins! Game over! (+{CONNECT_FOUR_WIN_POINTS} pts)" - ) if isinstance(player2, Member): - await add_points(self.bot, player2.id, CONNECT_FOUR_WIN_POINTS, "connect_four") + _, earned = await add_points(self.bot, player2.id, CONNECT_FOUR_WIN_POINTS, "connect_four") + 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: @@ -142,10 +150,17 @@ 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!" - f"{self.player_inactive.mention} wins! (+{CONNECT_FOUR_WIN_POINTS} pts)") if isinstance(self.player_inactive, Member): - await add_points(self.bot, self.player_inactive.id, CONNECT_FOUR_WIN_POINTS, "connect_four") + _, earned = await add_points(self.bot, self.player_inactive.id, CONNECT_FOUR_WIN_POINTS, "connect_four") + await self.channel.send( + f"{self.player_active.mention}, you took too long. Game over! " + f"{self.player_inactive.mention} wins! (+{earned} pts)" + ) + else: + await self.channel.send( + f"{self.player_active.mention}, you took too long. Game over! " + f"{self.player_inactive.mention} wins!" + ) return None else: await message.delete() diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index df6fc1cb30..2991aeb1ac 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -309,14 +309,16 @@ async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_messa DUCK_GAME_SECOND_PLACE_POINTS, DUCK_GAME_THIRD_PLACE_POINTS ] + earned_points = {} for rank, (member, score) in enumerate(scores[:3]): if score > 0: - await add_points(self.bot, member.id, point_awards[rank], "duck_game") + _, earned = await add_points(self.bot, member.id, point_awards[rank], "duck_game") + earned_points[member.id] = earned scoreboard = "Final scores:\n\n" for rank, (member, score) in enumerate(scores): - if rank < 3 and score > 0: - scoreboard += f"{member.display_name}: {score} (+{point_awards[rank]} global pts)\n" + if rank < 3 and score > 0 and member.id in earned_points: + scoreboard += f"{member.display_name}: {score} (+{earned_points[member.id]} global pts)\n" else: scoreboard += f"{member.display_name}: {score}\n" scoreboard_embed.description = scoreboard diff --git a/bot/exts/fun/hangman.py b/bot/exts/fun/hangman.py index f35a32dfa9..b563b59a4d 100644 --- a/bot/exts/fun/hangman.py +++ b/bot/exts/fun/hangman.py @@ -170,10 +170,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)) - await add_points(self.bot, ctx.author.id, HANGMAN_WIN_POINTS, "hangman") + _, earned = await add_points(self.bot, ctx.author.id, HANGMAN_WIN_POINTS, "hangman") win_embed = Embed( title="You won!", - description=f"The word was `{word}`. (+{HANGMAN_WIN_POINTS} pts)", + description=f"The word was `{word}`. (+{earned} pts)", color=Colours.grass_green ) await ctx.send(embed=win_embed) diff --git a/bot/exts/fun/minesweeper.py b/bot/exts/fun/minesweeper.py index d3b4fda64d..866f7d971e 100644 --- a/bot/exts/fun/minesweeper.py +++ b/bot/exts/fun/minesweeper.py @@ -199,8 +199,8 @@ async def won(self, ctx: commands.Context) -> None: """The player won the game.""" game = self.games[ctx.author.id] points = self.points_by_user.get(ctx.author.id, 6) - await add_points(self.bot, ctx.author.id, points, "minesweeper") - await ctx.author.send(f":tada: You won! :tada: (+{points} pts)") + _, earned = await add_points(self.bot, ctx.author.id, points, "minesweeper") + 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: (+{points} pts)" diff --git a/bot/exts/fun/rps.py b/bot/exts/fun/rps.py index db05c1afe6..ceafc0f5e2 100644 --- a/bot/exts/fun/rps.py +++ b/bot/exts/fun/rps.py @@ -53,8 +53,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 add_points(self.bot, ctx.author.id, RPS_WIN_POINTS, "rps") - await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} won! (+{RPS_WIN_POINTS} pts)") + _, earned = await add_points(self.bot, ctx.author.id, RPS_WIN_POINTS, "rps") + 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 eee446a726..770096a2a2 100644 --- a/bot/exts/fun/snakes/_snakes_cog.py +++ b/bot/exts/fun/snakes/_snakes_cog.py @@ -441,10 +441,10 @@ def predicate(reaction: Reaction, user: Member) -> bool: if str(reaction.emoji) == ANSWERS_EMOJI[answer]: if award_points is not None: - await add_points(self.bot, ctx.author.id, award_points, game) + _, earned = await add_points(self.bot, ctx.author.id, award_points, game) await ctx.send( f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**. " - f"(+{award_points} pts)" + f"(+{earned} pts)" ) else: await ctx.send(f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**.") diff --git a/bot/exts/fun/snakes/_utils.py b/bot/exts/fun/snakes/_utils.py index 2e4cbb6b33..dd41e8af77 100644 --- a/bot/exts/fun/snakes/_utils.py +++ b/bot/exts/fun/snakes/_utils.py @@ -687,10 +687,10 @@ async def _complete_round(self) -> None: return # announce winner and exit - await add_points(self.ctx.bot, winner.id, SNAKES_AND_LADDERS_WIN_POINTS, "snakes_and_ladders") + _, earned = await add_points(self.ctx.bot, winner.id, SNAKES_AND_LADDERS_WIN_POINTS, "snakes_and_ladders") await self.channel.send( f"**Snakes and Ladders**: {winner.mention} has won the game! :tada: " - f"(+{SNAKES_AND_LADDERS_WIN_POINTS} pts)" + f"(+{earned} pts)" ) self._destruct() diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py index 063f3fcdff..c3b9dfc114 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -225,8 +225,8 @@ async def play(self) -> None: # Only award points to real users (not the AI/bot) if isinstance(self.current, Player): - await add_points(self.ctx.bot, self.current.user.id, TIC_TAC_TOE_WIN_POINTS, "tic_tac_toe") - await self.ctx.send(f":tada: {self.current} won this game! :tada: (+{TIC_TAC_TOE_WIN_POINTS} pts)") + _, earned = await add_points(self.ctx.bot, self.current.user.id, TIC_TAC_TOE_WIN_POINTS, "tic_tac_toe") + 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:" diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index d30a1a2a76..f672650f99 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -493,10 +493,10 @@ def contains_correct_answer(m: discord.Message) -> bool: self.player_scores[msg.author] = points hint_no = 0 - await add_points(self.bot, msg.author.id, leaderboard_points, "quiz") + _, earned = await add_points(self.bot, msg.author.id, leaderboard_points, "quiz") await ctx.send( f"{msg.author.mention} got the correct answer :tada: " - f"{points} points! (+{leaderboard_points} global pts)" + f"{points} points! (+{earned} global pts)" ) await self.send_answer( diff --git a/bot/exts/holidays/easter/easter_riddle.py b/bot/exts/holidays/easter/easter_riddle.py index da87b26af5..d728e1581e 100644 --- a/bot/exts/holidays/easter/easter_riddle.py +++ b/bot/exts/holidays/easter/easter_riddle.py @@ -88,8 +88,8 @@ async def riddle(self, ctx: commands.Context) -> None: await ctx.send(embed=hint_embed) if winner_id is not None: - await add_points(self.bot, winner_id, EASTER_RIDDLE_WIN_POINTS, "easter_riddle") - content = f"Well done {winner} for getting it right! (+{EASTER_RIDDLE_WIN_POINTS} pts)" + new_total, earned = await add_points(self.bot, winner_id, EASTER_RIDDLE_WIN_POINTS, "easter_riddle") + content = f"Well done {winner} 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 203310d4f3..fdc31f4c8b 100644 --- a/bot/exts/holidays/easter/egghead_quiz.py +++ b/bot/exts/holidays/easter/egghead_quiz.py @@ -107,16 +107,20 @@ async def eggquiz(self, ctx: commands.Context) -> None: winners = [u for u in users if not u.bot] + points_earned = {} for u in winners: - await add_points(ctx.bot, u.id, EGGQUIZ_WIN_POINTS, "eggquiz") + _, earned = await add_points(ctx.bot, u.id, EGGQUIZ_WIN_POINTS, "eggquiz") + points_earned[u.id] = earned mentions = " ".join(u.mention for u in winners) - content = ( - f"Well done {mentions} for getting it correct! (+{EGGQUIZ_WIN_POINTS} pts)" - if mentions - else "Nobody got it right..." - ) + if winners and len(set(points_earned.values())) == 1: + pts = list(points_earned.values())[0] + 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/utils/leaderboard.py b/bot/utils/leaderboard.py index 79d579c8c3..33f2620566 100644 --- a/bot/utils/leaderboard.py +++ b/bot/utils/leaderboard.py @@ -30,20 +30,22 @@ async def _get_points_cache() -> RedisCache: return Leaderboard.points_cache -async def add_points(bot: Bot, user_id: int, points: int, game_name: str) -> int: +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 the user's new total score, or 0 if the cog is not loaded. + Returns a tuple of (new_total_score, points_actually_earned). + Returns (0, 0) if the cog is not loaded. """ if points <= 0: - return await get_user_points(bot, user_id) + total = await get_user_points(bot, user_id) + return (total, 0) if bot.get_cog("Leaderboard") is None: - return 0 + return (0, 0) redis = bot.redis_session.client daily_key = _daily_key(user_id, game_name) @@ -55,22 +57,24 @@ async def add_points(bot: Bot, user_id: int, points: int, game_name: str) -> int remaining = DAILY_POINT_CAP - earned_today if remaining <= 0: log.trace(f"User {user_id} hit daily cap for {game_name}, skipping.") - return await get_user_points(bot, user_id) + total = await get_user_points(bot, user_id) + return (total, 0) # clamp to remaining daily allowance - points = min(points, remaining) + points_earned = min(points, remaining) ttl = seconds_until_midnight_utc() - await redis.set(daily_key, earned_today + points, ex=ttl) + await redis.set(daily_key, earned_today + points_earned, ex=ttl) # update persistent global total points_cache = await _get_points_cache() if await points_cache.contains(user_id): - await points_cache.increment(user_id, points) + await points_cache.increment(user_id, points_earned) else: - await points_cache.set(user_id, points) + await points_cache.set(user_id, points_earned) - return int(await points_cache.get(user_id)) + 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: From 493339a75dd7a3d501c25c623f6333bc9d0722e6 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Sun, 1 Mar 2026 14:19:07 +0100 Subject: [PATCH 10/31] Fix: pre-commit & ruff check errors --- bot/exts/fun/anagram.py | 1 - bot/exts/fun/connect_four.py | 4 +++- bot/exts/fun/tic_tac_toe.py | 8 ++++++-- bot/exts/holidays/easter/egghead_quiz.py | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py index ab5327c023..9cddd8a3b3 100644 --- a/bot/exts/fun/anagram.py +++ b/bot/exts/fun/anagram.py @@ -86,7 +86,6 @@ async def anagram_command(self, ctx: commands.Context) -> None: for winner_id in game.winner_ids: _, earned = await add_points(self.bot, winner_id, ANAGRAM_WIN_POINTS, "anagram") 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)" diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index 44a965f564..cfa7a47053 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -151,7 +151,9 @@ async def player_turn(self) -> Coordinate: reaction, user = await self.bot.wait_for("reaction_add", check=self.predicate, timeout=30.0) except TimeoutError: if isinstance(self.player_inactive, Member): - _, earned = await add_points(self.bot, self.player_inactive.id, CONNECT_FOUR_WIN_POINTS, "connect_four") + _, earned = await add_points( + self.bot, self.player_inactive.id, CONNECT_FOUR_WIN_POINTS, "connect_four" + ) await self.channel.send( f"{self.player_active.mention}, you took too long. Game over! " f"{self.player_inactive.mention} wins! (+{earned} pts)" diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py index c3b9dfc114..6e4488d5cc 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -225,8 +225,12 @@ async def play(self) -> None: # 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") - await self.ctx.send(f":tada: {self.current} won this game! :tada: (+{earned} pts)") + _, earned = await add_points( + self.ctx.bot, self.current.user.id, TIC_TAC_TOE_WIN_POINTS, "tic_tac_toe" + ) + 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:" diff --git a/bot/exts/holidays/easter/egghead_quiz.py b/bot/exts/holidays/easter/egghead_quiz.py index fdc31f4c8b..c1f9e91b33 100644 --- a/bot/exts/holidays/easter/egghead_quiz.py +++ b/bot/exts/holidays/easter/egghead_quiz.py @@ -115,7 +115,7 @@ async def eggquiz(self, ctx: commands.Context) -> None: mentions = " ".join(u.mention for u in winners) if winners and len(set(points_earned.values())) == 1: - pts = list(points_earned.values())[0] + 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!" From 11326e8ff711fd42951e487dde422049b2887822 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Sun, 1 Mar 2026 15:01:04 +0100 Subject: [PATCH 11/31] Fix: handle ties & optimize get_user_rank --- bot/exts/fun/leaderboard.py | 16 +++++++++++----- bot/utils/leaderboard.py | 26 ++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/bot/exts/fun/leaderboard.py b/bot/exts/fun/leaderboard.py index a1aab1bb3f..b92826be8f 100644 --- a/bot/exts/fun/leaderboard.py +++ b/bot/exts/fun/leaderboard.py @@ -23,11 +23,17 @@ def _format_leaderboard_lines(records: list[tuple[int, int]]) -> list[str]: """Format leaderboard records into display lines.""" lines = [] - for rank, (user_id, score) in enumerate(records): - if rank < len(MEDALS): - prefix = MEDALS[rank] + 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 + 1}**" + prefix = f"**#{rank}**" lines.append(f"{prefix} <@{user_id}>: **{score}** pts") return lines @@ -92,7 +98,7 @@ async def leaderboard_command(self, ctx: commands.Context) -> None: 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) + 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: diff --git a/bot/utils/leaderboard.py b/bot/utils/leaderboard.py index 33f2620566..68822d540c 100644 --- a/bot/utils/leaderboard.py +++ b/bot/utils/leaderboard.py @@ -146,12 +146,30 @@ async def get_daily_leaderboard(bot: Bot) -> list[tuple[int, int]]: ) -async def get_user_rank(bot: Bot, user_id: int) -> int | None: - """Get a user's rank on the global leaderboard, or None if unranked.""" - leaderboard = await get_leaderboard(bot) - for rank, (uid, _score) in enumerate(leaderboard, start=1): +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 From 8114a47ed12eabca4c69bf6e5c7a6b07a1e7052c Mon Sep 17 00:00:00 2001 From: Ammar Alzeno <152777108+ammaralzeno@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:54:59 +0100 Subject: [PATCH 12/31] Update bot/exts/fun/snakes/_snakes_cog.py Co-authored-by: Jacob Christensen --- bot/exts/fun/snakes/_snakes_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/fun/snakes/_snakes_cog.py b/bot/exts/fun/snakes/_snakes_cog.py index 770096a2a2..9058291913 100644 --- a/bot/exts/fun/snakes/_snakes_cog.py +++ b/bot/exts/fun/snakes/_snakes_cog.py @@ -443,8 +443,8 @@ def predicate(reaction: Reaction, user: Member) -> bool: if award_points is not None: _, earned = await add_points(self.bot, ctx.author.id, award_points, game) await ctx.send( - f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**. " - f"(+{earned} pts)" + f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**. " + f"(+{earned} pts)" ) else: await ctx.send(f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**.") From f4903963dcb1115cdb27f5d3a920b96605be73a3 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno <152777108+ammaralzeno@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:07:04 +0100 Subject: [PATCH 13/31] Update bot/exts/fun/connect_four.py Co-authored-by: Jacob Christensen --- bot/exts/fun/connect_four.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index cfa7a47053..880e81671b 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -150,19 +150,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: + 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" ) - await self.channel.send( - f"{self.player_active.mention}, you took too long. Game over! " - f"{self.player_inactive.mention} wins! (+{earned} pts)" - ) - else: - await self.channel.send( - f"{self.player_active.mention}, you took too long. Game over! " - f"{self.player_inactive.mention} wins!" - ) + message += f" (+{earned} pts)" + await self.channel.send(message) return None else: await message.delete() From 77b3d003574a9d08058830afd9fc163230a98c32 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno <152777108+ammaralzeno@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:14:56 +0100 Subject: [PATCH 14/31] Update bot/utils/leaderboard.py Co-authored-by: Jacob Christensen --- bot/utils/leaderboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/leaderboard.py b/bot/utils/leaderboard.py index 68822d540c..45c52cbb6c 100644 --- a/bot/utils/leaderboard.py +++ b/bot/utils/leaderboard.py @@ -114,7 +114,7 @@ async def get_leaderboard(bot: Bot) -> list[tuple[int, int]]: return sorted( ((int(user_id), int(score)) for user_id, score in records if int(score) > 0), - key=lambda x: x[1], + key=operator.itemgetter(1), reverse=True, ) From 30afb24658cc89258e24ea6c3cdf4653c3c98132 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Mon, 2 Mar 2026 18:24:51 +0100 Subject: [PATCH 15/31] Add: operator import --- bot/utils/leaderboard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/utils/leaderboard.py b/bot/utils/leaderboard.py index 45c52cbb6c..5437295d6d 100644 --- a/bot/utils/leaderboard.py +++ b/bot/utils/leaderboard.py @@ -1,5 +1,6 @@ from __future__ import annotations +import operator from typing import TYPE_CHECKING from async_rediscache import RedisCache From ac2254c3d56142a8a72d079bce447bc5d9759ec6 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Mon, 2 Mar 2026 18:57:45 +0100 Subject: [PATCH 16/31] Add: module-level game name constant --- bot/exts/fun/anagram.py | 3 ++- bot/exts/fun/battleship.py | 11 +++++++---- bot/exts/fun/coinflip.py | 3 ++- bot/exts/fun/connect_four.py | 5 +++-- bot/exts/fun/duck_game.py | 3 ++- bot/exts/fun/hangman.py | 3 ++- bot/exts/fun/minesweeper.py | 3 ++- bot/exts/fun/rps.py | 3 ++- bot/exts/fun/snakes/_snakes_cog.py | 6 +++--- bot/exts/fun/snakes/_utils.py | 5 ++++- bot/exts/fun/tic_tac_toe.py | 3 ++- bot/exts/fun/trivia_quiz.py | 3 ++- bot/exts/holidays/easter/easter_riddle.py | 3 ++- bot/exts/holidays/easter/egghead_quiz.py | 3 ++- 14 files changed, 37 insertions(+), 20 deletions(-) diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py index 9cddd8a3b3..ed2f480e29 100644 --- a/bot/exts/fun/anagram.py +++ b/bot/exts/fun/anagram.py @@ -12,6 +12,7 @@ from bot.utils.leaderboard import add_points ANAGRAM_WIN_POINTS = 10 +ANAGRAM_GAME_NAME = "anagram" log = get_logger(__name__) @@ -84,7 +85,7 @@ async def anagram_command(self, ctx: commands.Context) -> None: win_list = ", ".join(game.winners) points_earned = set() for winner_id in game.winner_ids: - _, earned = await add_points(self.bot, winner_id, ANAGRAM_WIN_POINTS, "anagram") + _, 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() diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py index 5db8d29b9f..1469f2036d 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -12,6 +12,7 @@ from bot.utils.leaderboard import add_points BATTLESHIP_WIN_POINTS = 30 +BATTLESHIP_GAME_NAME = "battleship" log = get_logger(__name__) @@ -154,7 +155,7 @@ async def game_over( loser: discord.Member ) -> None: """Removes games from list of current games and announces to public chat.""" - _, earned = await add_points(self.bot, winner.id, BATTLESHIP_WIN_POINTS, "battleship") + _, 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 i, player in enumerate((self.p1, self.p2)): @@ -251,7 +252,7 @@ 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") + _, 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"(+{earned} pts)" @@ -261,7 +262,9 @@ async def take_turn(self) -> Square | None: 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") + _, 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"(+{earned} pts)" @@ -284,7 +287,7 @@ 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): - _, earned = await add_points(self.bot, self.turn.user.id, BATTLESHIP_WIN_POINTS, "battleship") + _, 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 diff --git a/bot/exts/fun/coinflip.py b/bot/exts/fun/coinflip.py index 2a037dddac..5368533935 100644 --- a/bot/exts/fun/coinflip.py +++ b/bot/exts/fun/coinflip.py @@ -7,6 +7,7 @@ from bot.utils.leaderboard import add_points COINFLIP_WIN_POINTS = 2 +COINFLIP_GAME_NAME = "coinflip" class CoinSide(commands.Converter): @@ -48,7 +49,7 @@ async def coinflip_command(self, ctx: commands.Context, side: CoinSide = None) - return if side == flipped_side: - _, earned = await add_points(self.bot, ctx.author.id, COINFLIP_WIN_POINTS, "coinflip") + _, 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}" diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index 880e81671b..219468eb20 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -11,6 +11,7 @@ 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 @@ -79,7 +80,7 @@ async def game_over( """Announces to public chat.""" if action == "win": if isinstance(player1, Member): - _, earned = await add_points(self.bot, player1.id, CONNECT_FOUR_WIN_POINTS, "connect_four") + _, 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)" ) @@ -91,7 +92,7 @@ async def game_over( await self.channel.send(f"Game Over! {player1.mention} {player2.mention} It's A Draw :tada:") elif action == "quit": if isinstance(player2, Member): - _, earned = await add_points(self.bot, player2.id, CONNECT_FOUR_WIN_POINTS, "connect_four") + _, 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)" ) diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index 2991aeb1ac..bebeb5f6f7 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -18,6 +18,7 @@ DUCK_GAME_FIRST_PLACE_POINTS = 30 DUCK_GAME_SECOND_PLACE_POINTS = 20 DUCK_GAME_THIRD_PLACE_POINTS = 10 +DUCK_GAME_NAME = "duck_game" DECK = list(product(*[(0, 1, 2)]*4)) @@ -312,7 +313,7 @@ async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_messa earned_points = {} for rank, (member, score) in enumerate(scores[:3]): if score > 0: - _, earned = await add_points(self.bot, member.id, point_awards[rank], "duck_game") + _, earned = await add_points(self.bot, member.id, point_awards[rank], DUCK_GAME_NAME) earned_points[member.id] = earned scoreboard = "Final scores:\n\n" diff --git a/bot/exts/fun/hangman.py b/bot/exts/fun/hangman.py index b563b59a4d..6e36ad10c0 100644 --- a/bot/exts/fun/hangman.py +++ b/bot/exts/fun/hangman.py @@ -9,6 +9,7 @@ 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() @@ -170,7 +171,7 @@ 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") + _, 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}`. (+{earned} pts)", diff --git a/bot/exts/fun/minesweeper.py b/bot/exts/fun/minesweeper.py index 866f7d971e..258f62e581 100644 --- a/bot/exts/fun/minesweeper.py +++ b/bot/exts/fun/minesweeper.py @@ -13,6 +13,7 @@ from bot.utils.leaderboard import add_points MINESWEEPER_WIN_POINTS = 15 +MINESWEEPER_GAME_NAME = "minesweeper" MESSAGE_MAPPING = { 0: ":stop_button:", @@ -199,7 +200,7 @@ async def won(self, ctx: commands.Context) -> None: """The player won the game.""" game = self.games[ctx.author.id] points = self.points_by_user.get(ctx.author.id, 6) - _, earned = await add_points(self.bot, ctx.author.id, points, "minesweeper") + _, 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( diff --git a/bot/exts/fun/rps.py b/bot/exts/fun/rps.py index ceafc0f5e2..d82b2f0fd5 100644 --- a/bot/exts/fun/rps.py +++ b/bot/exts/fun/rps.py @@ -6,6 +6,7 @@ from bot.utils.leaderboard import add_points RPS_WIN_POINTS = 2 +RPS_GAME_NAME = "rps" CHOICES = ["rock", "paper", "scissors"] SHORT_CHOICES = ["r", "p", "s"] @@ -53,7 +54,7 @@ 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: - _, earned = await add_points(self.bot, ctx.author.id, RPS_WIN_POINTS, "rps") + _, 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 9058291913..f114dda952 100644 --- a/bot/exts/fun/snakes/_snakes_cog.py +++ b/bot/exts/fun/snakes/_snakes_cog.py @@ -25,6 +25,7 @@ from bot.utils.leaderboard import add_points SNAKE_QUIZ_WIN_POINTS = 10 +SNAKE_QUIZ_GAME_NAME = "snakes_quiz" log = get_logger(__name__) @@ -417,7 +418,6 @@ async def _validate_answer( options: dict[str, str], *, award_points: int | None = None, - game: str = "snakes_quiz", ) -> None: """Validate the answer using a reaction event loop.""" def predicate(reaction: Reaction, user: Member) -> bool: @@ -441,7 +441,7 @@ def predicate(reaction: Reaction, user: Member) -> bool: if str(reaction.emoji) == ANSWERS_EMOJI[answer]: if award_points is not None: - _, earned = await add_points(self.bot, ctx.author.id, award_points, game) + _, 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)" @@ -864,7 +864,7 @@ async def quiz_command(self, ctx: Context) -> None: ) quiz = await ctx.send(embed=embed) - await self._validate_answer(ctx, quiz, answer, options, award_points=SNAKE_QUIZ_WIN_POINTS, game="snakes_quiz") + 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 dd41e8af77..aaf60fcafd 100644 --- a/bot/exts/fun/snakes/_utils.py +++ b/bot/exts/fun/snakes/_utils.py @@ -15,6 +15,7 @@ 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() @@ -687,7 +688,9 @@ async def _complete_round(self) -> None: return # announce winner and exit - _, earned = await add_points(self.ctx.bot, winner.id, SNAKES_AND_LADDERS_WIN_POINTS, "snakes_and_ladders") + _, 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)" diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py index 6e4488d5cc..23adcd4994 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -10,6 +10,7 @@ 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." @@ -226,7 +227,7 @@ async def play(self) -> None: # 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" + 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)" diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index f672650f99..e27159332b 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -22,6 +22,7 @@ logger = get_logger(__name__) DEFAULT_QUESTION_LIMIT = 7 +TRIVIA_QUIZ_GAME_NAME = "quiz" STANDARD_VARIATION_TOLERANCE = 88 DYNAMICALLY_GEN_VARIATION_TOLERANCE = 97 @@ -493,7 +494,7 @@ def contains_correct_answer(m: discord.Message) -> bool: self.player_scores[msg.author] = points hint_no = 0 - _, earned = await add_points(self.bot, msg.author.id, leaderboard_points, "quiz") + _, 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} global pts)" diff --git a/bot/exts/holidays/easter/easter_riddle.py b/bot/exts/holidays/easter/easter_riddle.py index d728e1581e..1aecc8a3a9 100644 --- a/bot/exts/holidays/easter/easter_riddle.py +++ b/bot/exts/holidays/easter/easter_riddle.py @@ -11,6 +11,7 @@ from bot.utils.leaderboard import add_points EASTER_RIDDLE_WIN_POINTS = 10 +EASTER_RIDDLE_GAME_NAME = "easter_riddle" log = get_logger(__name__) @@ -88,7 +89,7 @@ async def riddle(self, ctx: commands.Context) -> None: await ctx.send(embed=hint_embed) if winner_id is not None: - new_total, earned = await add_points(self.bot, winner_id, EASTER_RIDDLE_WIN_POINTS, "easter_riddle") + new_total, earned = await add_points(self.bot, winner_id, EASTER_RIDDLE_WIN_POINTS, EASTER_RIDDLE_GAME_NAME) content = f"Well done {winner} 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 c1f9e91b33..61bba3a09f 100644 --- a/bot/exts/holidays/easter/egghead_quiz.py +++ b/bot/exts/holidays/easter/egghead_quiz.py @@ -12,6 +12,7 @@ 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")) @@ -109,7 +110,7 @@ async def eggquiz(self, ctx: commands.Context) -> None: points_earned = {} for u in winners: - _, earned = await add_points(ctx.bot, u.id, EGGQUIZ_WIN_POINTS, "eggquiz") + _, 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) From a6328761c13ff9574577a247d06fc3b5988d1f73 Mon Sep 17 00:00:00 2001 From: norawennerstrom Date: Mon, 2 Mar 2026 20:26:36 +0100 Subject: [PATCH 17/31] Fix: change 'winners' from list to tuple --- bot/exts/holidays/easter/egghead_quiz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/holidays/easter/egghead_quiz.py b/bot/exts/holidays/easter/egghead_quiz.py index 61bba3a09f..cce098f53c 100644 --- a/bot/exts/holidays/easter/egghead_quiz.py +++ b/bot/exts/holidays/easter/egghead_quiz.py @@ -106,7 +106,7 @@ async def eggquiz(self, ctx: commands.Context) -> None: # with the correct answer, so stop looping over reactions. break - winners = [u for u in users if not u.bot] + winners = tuple(u for u in users if not u.bot) points_earned = {} for u in winners: From 101965af01ae4abeab59dcd6ff36284878fadf67 Mon Sep 17 00:00:00 2001 From: Anna Remmare Date: Mon, 2 Mar 2026 21:28:13 +0100 Subject: [PATCH 18/31] refactor: duck game scoring --- bot/exts/fun/duck_game.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index bebeb5f6f7..a724854c01 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -18,6 +18,11 @@ 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)) @@ -304,21 +309,18 @@ async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_messa reverse=True, ) - # Award leaderboard points to top 3 players - point_awards = [ - DUCK_GAME_FIRST_PLACE_POINTS, - DUCK_GAME_SECOND_PLACE_POINTS, - DUCK_GAME_THIRD_PLACE_POINTS - ] + # Award leaderboard points to top finishers (number of places from DUCK_GAME_POINT_AWARDS) earned_points = {} - for rank, (member, score) in enumerate(scores[:3]): + for rank, (member, score) in enumerate(scores[:len(DUCK_GAME_POINT_AWARDS)]): if score > 0: - _, earned = await add_points(self.bot, member.id, point_awards[rank], DUCK_GAME_NAME) + _, 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" for rank, (member, score) in enumerate(scores): - if rank < 3 and score > 0 and member.id in earned_points: + 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]} global pts)\n" else: scoreboard += f"{member.display_name}: {score}\n" From 8a4ac27a484f0e2167b7a7c092f923f18e10bad0 Mon Sep 17 00:00:00 2001 From: norawennerstrom Date: Mon, 2 Mar 2026 21:30:40 +0100 Subject: [PATCH 19/31] Fix: call 'increment' directly without checking that cache contains user ID --- bot/utils/leaderboard.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/utils/leaderboard.py b/bot/utils/leaderboard.py index 5437295d6d..a35f68c317 100644 --- a/bot/utils/leaderboard.py +++ b/bot/utils/leaderboard.py @@ -69,10 +69,7 @@ async def add_points(bot: Bot, user_id: int, points: int, game_name: str) -> tup # update persistent global total points_cache = await _get_points_cache() - if await points_cache.contains(user_id): - await points_cache.increment(user_id, points_earned) - else: - await points_cache.set(user_id, points_earned) + await points_cache.increment(user_id, points_earned) new_total = int(await points_cache.get(user_id)) return (new_total, points_earned) From da6c010467746a164bf4af66bb1fa7268c31984a Mon Sep 17 00:00:00 2001 From: Anna Remmare Date: Mon, 2 Mar 2026 21:38:51 +0100 Subject: [PATCH 20/31] refactor: remove redundant award_points check in snakes --- bot/exts/fun/snakes/_snakes_cog.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/exts/fun/snakes/_snakes_cog.py b/bot/exts/fun/snakes/_snakes_cog.py index f114dda952..7baf1b77e0 100644 --- a/bot/exts/fun/snakes/_snakes_cog.py +++ b/bot/exts/fun/snakes/_snakes_cog.py @@ -417,7 +417,7 @@ async def _validate_answer( answer: str, options: dict[str, str], *, - award_points: int | None = None, + award_points: int, ) -> None: """Validate the answer using a reaction event loop.""" def predicate(reaction: Reaction, user: Member) -> bool: @@ -440,14 +440,11 @@ def predicate(reaction: Reaction, user: Member) -> bool: return if str(reaction.emoji) == ANSWERS_EMOJI[answer]: - if award_points is not None: - _, 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(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]}**." From 554d380bc19f7f425f38315cc0a47876e77e2159 Mon Sep 17 00:00:00 2001 From: Amanda Date: Mon, 2 Mar 2026 21:44:43 +0100 Subject: [PATCH 21/31] Refactor: "me"-command the function leaderboard_me now invokes leaderboard_user with user=ctx.author --- bot/exts/fun/leaderboard.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/bot/exts/fun/leaderboard.py b/bot/exts/fun/leaderboard.py index b92826be8f..a0bc556cfd 100644 --- a/bot/exts/fun/leaderboard.py +++ b/bot/exts/fun/leaderboard.py @@ -147,19 +147,7 @@ async def leaderboard_today(self, ctx: commands.Context) -> None: @leaderboard_command.command(name="me") async def leaderboard_me(self, ctx: commands.Context) -> None: """Show your own global points.""" - score = await get_user_points(self.bot, ctx.author.id) - rank = await get_user_rank(self.bot, ctx.author.id) - - description = f"{ctx.author.mention}: **{score}** pts" - if rank: - description += f" (Rank #{rank})" - - embed = discord.Embed( - colour=Colours.blue, - title="Your Global Points", - description=description, - ) - await ctx.send(embed=embed) + await self.leaderboard_user(ctx, ctx.author) @leaderboard_command.command(name="user") async def leaderboard_user(self, ctx: commands.Context, user: discord.User) -> None: From d2e1747645c9fc4c8d0ea847f81e9befb065c6ca Mon Sep 17 00:00:00 2001 From: Amanda Date: Mon, 2 Mar 2026 21:54:52 +0100 Subject: [PATCH 22/31] Refactor: easter_riddle winner variable cleanup winner variable is now simply set to response.author --- bot/exts/holidays/easter/easter_riddle.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/exts/holidays/easter/easter_riddle.py b/bot/exts/holidays/easter/easter_riddle.py index 1aecc8a3a9..43654f93ea 100644 --- a/bot/exts/holidays/easter/easter_riddle.py +++ b/bot/exts/holidays/easter/easter_riddle.py @@ -63,7 +63,6 @@ async def riddle(self, ctx: commands.Context) -> None: await ctx.send(embed=riddle_embed) hint_number = 0 winner = None - winner_id = None while hint_number < 3: try: response = await self.bot.wait_for( @@ -73,8 +72,7 @@ async def riddle(self, ctx: commands.Context) -> None: and m.content.lower() == correct.lower(), timeout=TIMELIMIT, ) - winner = response.author.mention - winner_id = response.author.id + winner = response.author break except TimeoutError: hint_number += 1 @@ -88,9 +86,9 @@ async def riddle(self, ctx: commands.Context) -> None: break await ctx.send(embed=hint_embed) - if winner_id 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} for getting it right! (+{earned} pts)" + if winner.id 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..." From ead4028fddd5bf610933096f44fe4780620bb3e5 Mon Sep 17 00:00:00 2001 From: Amanda Date: Mon, 2 Mar 2026 22:18:36 +0100 Subject: [PATCH 23/31] Fix: replace "global pts" with "pts" in end_game() --- bot/exts/fun/duck_game.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index bebeb5f6f7..ff5de9e64b 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -319,7 +319,7 @@ async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_messa scoreboard = "Final scores:\n\n" for rank, (member, score) in enumerate(scores): if rank < 3 and score > 0 and member.id in earned_points: - scoreboard += f"{member.display_name}: {score} (+{earned_points[member.id]} global pts)\n" + scoreboard += f"{member.display_name}: {score} (+{earned_points[member.id]} pts)\n" else: scoreboard += f"{member.display_name}: {score}\n" scoreboard_embed.description = scoreboard From 606951bf1e1f429f3ddad4f5eb31a402f9934dcc Mon Sep 17 00:00:00 2001 From: Amanda Date: Mon, 2 Mar 2026 22:21:39 +0100 Subject: [PATCH 24/31] Fix:replace "global pts" with "pts" in quiz_game() --- bot/exts/fun/trivia_quiz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index e27159332b..a54946b6cb 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -497,7 +497,7 @@ def contains_correct_answer(m: discord.Message) -> bool: _, 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} global pts)" + f"{points} points! (+{earned} pts)" ) await self.send_answer( From e07b1812b64d20cfb72dab2d8d6fcba97c193c99 Mon Sep 17 00:00:00 2001 From: norawennerstrom Date: Mon, 2 Mar 2026 23:28:53 +0100 Subject: [PATCH 25/31] Fix: move points_cache from cog to global variable in bot.utils.leaderboard --- bot/exts/fun/leaderboard.py | 15 +++++++++++---- bot/utils/leaderboard.py | 32 ++++++++++++-------------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/bot/exts/fun/leaderboard.py b/bot/exts/fun/leaderboard.py index b92826be8f..88ea7dde38 100644 --- a/bot/exts/fun/leaderboard.py +++ b/bot/exts/fun/leaderboard.py @@ -6,7 +6,7 @@ from bot.bot import Bot from bot.constants import Colours, MODERATION_ROLES from bot.utils.decorators import with_role -from bot.utils.leaderboard import get_daily_leaderboard, get_leaderboard, get_user_points, get_user_rank +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 = ( @@ -58,10 +58,12 @@ async def interaction_check(self, interaction: Interaction) -> bool: @ui.button(label="Confirm", style=ButtonStyle.danger) async def confirm(self, interaction: Interaction, _button: ui.Button) -> None: """Clear the leaderboard on confirmation.""" - from bot.utils.leaderboard import _get_points_cache + if POINTS_CACHE is None: + await interaction.response.send_message("Leaderboard cache is not initialized.") + self.stop() + return - points_cache = await _get_points_cache() - await points_cache.clear() + await POINTS_CACHE.clear() await interaction.response.send_message("Leaderboard has been cleared.") self.stop() @@ -80,6 +82,11 @@ class Leaderboard(commands.Cog): 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 + @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.""" diff --git a/bot/utils/leaderboard.py b/bot/utils/leaderboard.py index a35f68c317..f223780bc5 100644 --- a/bot/utils/leaderboard.py +++ b/bot/utils/leaderboard.py @@ -19,18 +19,15 @@ # 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 _get_points_cache() -> RedisCache: - """Get the persistent points cache from the Leaderboard cog.""" - from bot.exts.fun.leaderboard import Leaderboard - return Leaderboard.points_cache - - 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. @@ -68,10 +65,9 @@ async def add_points(bot: Bot, user_id: int, points: int, game_name: str) -> tup await redis.set(daily_key, earned_today + points_earned, ex=ttl) # update persistent global total - points_cache = await _get_points_cache() - await points_cache.increment(user_id, points_earned) + await POINTS_CACHE.increment(user_id, points_earned) - new_total = int(await points_cache.get(user_id)) + new_total = int(await POINTS_CACHE.get(user_id)) return (new_total, points_earned) @@ -82,18 +78,16 @@ async def remove_points(bot: Bot, user_id: int, points: int) -> int: 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: + if points <= 0 or bot.get_cog("Leaderboard") is None or POINTS_CACHE is None: return await get_user_points(bot, user_id) - points_cache = await _get_points_cache() - - current = await points_cache.get(user_id) + 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) + await POINTS_CACHE.decrement(user_id, to_remove) return current - to_remove @@ -104,11 +98,10 @@ async def get_leaderboard(bot: Bot) -> list[tuple[int, int]]: Returns a list of (user_id, score) tuples sorted by score descending. """ - if bot.get_cog("Leaderboard") is None: + if bot.get_cog("Leaderboard") is None or POINTS_CACHE is None: return [] - points_cache = await _get_points_cache() - records = await points_cache.items() + records = await POINTS_CACHE.items() return sorted( ((int(user_id), int(score)) for user_id, score in records if int(score) > 0), @@ -173,9 +166,8 @@ async def get_user_rank( 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: + if bot.get_cog("Leaderboard") is None or POINTS_CACHE is None: return 0 - points_cache = await _get_points_cache() - score = await points_cache.get(user_id) + score = await POINTS_CACHE.get(user_id) return int(score) if score else 0 From 474ef43a1e538f69060b8211b65005c2ed05090e Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Tue, 3 Mar 2026 13:42:44 +0100 Subject: [PATCH 26/31] Fix: 6 as default value in minesweeper --- bot/exts/fun/minesweeper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/fun/minesweeper.py b/bot/exts/fun/minesweeper.py index 258f62e581..940cf27c29 100644 --- a/bot/exts/fun/minesweeper.py +++ b/bot/exts/fun/minesweeper.py @@ -199,7 +199,7 @@ 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] - points = self.points_by_user.get(ctx.author.id, 6) + 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: From 6d9c58e71fa080f447c9a3451782f1cd014d069f Mon Sep 17 00:00:00 2001 From: Ammar Alzeno <152777108+ammaralzeno@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:33:55 +0100 Subject: [PATCH 27/31] Update bot/exts/fun/connect_four.py Co-authored-by: Jacob Christensen --- bot/exts/fun/connect_four.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index 219468eb20..60b0b8abcf 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -157,7 +157,7 @@ async def player_turn(self) -> Coordinate: ) if isinstance(self.player_inactive, Member): _, earned = await add_points( - self.bot, self.player_inactive.id, CONNECT_FOUR_WIN_POINTS, "connect_four" + self.bot, self.player_inactive.id, CONNECT_FOUR_WIN_POINTS, CONNECT_FOUR_GAME_NAME ) message += f" (+{earned} pts)" await self.channel.send(message) From dd585fa67a0ecd3b0ec78b291c1a619a094064a4 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno <152777108+ammaralzeno@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:36:08 +0100 Subject: [PATCH 28/31] Update bot/utils/leaderboard.py Co-authored-by: rf20008 <64931063+rf20008@users.noreply.github.com> --- bot/utils/leaderboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/leaderboard.py b/bot/utils/leaderboard.py index f223780bc5..0326d32d10 100644 --- a/bot/utils/leaderboard.py +++ b/bot/utils/leaderboard.py @@ -114,7 +114,7 @@ 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 descending. + Returns a list of (user_id, total_daily_score) tuples sorted in descending order. """ if bot.get_cog("Leaderboard") is None: return [] From 68a9b7bc2980c10f9af421156e73b717c2307b90 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Thu, 12 Mar 2026 14:46:35 +0100 Subject: [PATCH 29/31] Add: cog_unload to reset POINTS_CACHE --- bot/exts/fun/leaderboard.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/exts/fun/leaderboard.py b/bot/exts/fun/leaderboard.py index 940cfbebbd..86fd83c64f 100644 --- a/bot/exts/fun/leaderboard.py +++ b/bot/exts/fun/leaderboard.py @@ -87,6 +87,11 @@ async def cog_load(self) -> None: 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.""" From 91ee6af1fcd10512a595a7fa650627a1fab13a06 Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Thu, 12 Mar 2026 14:50:01 +0100 Subject: [PATCH 30/31] Remove: unnecessary get_user_points() call --- bot/utils/leaderboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/leaderboard.py b/bot/utils/leaderboard.py index 0326d32d10..15ea203eb9 100644 --- a/bot/utils/leaderboard.py +++ b/bot/utils/leaderboard.py @@ -79,7 +79,7 @@ async def remove_points(bot: Bot, user_id: int, points: int) -> int: or 0 if the cog is not loaded. """ if points <= 0 or bot.get_cog("Leaderboard") is None or POINTS_CACHE is None: - return await get_user_points(bot, user_id) + return 0 current = await POINTS_CACHE.get(user_id) if not current: From 8673c43165d9ac42bc9bc3b0971fb975db14962d Mon Sep 17 00:00:00 2001 From: Ammar Alzeno Date: Thu, 12 Mar 2026 14:57:21 +0100 Subject: [PATCH 31/31] Fix: AttributeError if winner is None --- bot/exts/holidays/easter/easter_riddle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/holidays/easter/easter_riddle.py b/bot/exts/holidays/easter/easter_riddle.py index 43654f93ea..97ed19ce79 100644 --- a/bot/exts/holidays/easter/easter_riddle.py +++ b/bot/exts/holidays/easter/easter_riddle.py @@ -86,7 +86,7 @@ async def riddle(self, ctx: commands.Context) -> None: break await ctx.send(embed=hint_embed) - if winner.id is not None: + 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: