From fa747ce81f66be09b3adb88d7962b23d1d841c50 Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:01:32 +0100 Subject: [PATCH 01/14] feat: added RNGdle profile, server stats, and leaderboard UI --- .../c818d80153fa_add_badges_to_rngdle.py | 18 + commands/cogs/rngdle.py | 282 +++++++++++++- utils/database/dao/rngdle.py | 23 +- utils/image_generator.py | 345 +++++++++++++++++- utils/rngdle.py | 9 +- 5 files changed, 651 insertions(+), 26 deletions(-) diff --git a/alembic/versions/c818d80153fa_add_badges_to_rngdle.py b/alembic/versions/c818d80153fa_add_badges_to_rngdle.py index 7f642de..91004c7 100644 --- a/alembic/versions/c818d80153fa_add_badges_to_rngdle.py +++ b/alembic/versions/c818d80153fa_add_badges_to_rngdle.py @@ -5,7 +5,10 @@ Create Date: 2026-08-04 18:20:03.233864 """ +<<<<<<< HEAD +======= +>>>>>>> upstream/master from typing import Sequence, Union from alembic import op @@ -13,8 +16,13 @@ # revision identifiers, used by Alembic. +<<<<<<< HEAD +revision: str = 'c818d80153fa' +down_revision: Union[str, Sequence[str], None] = 'af8e3b2c1d5f' +======= revision: str = "c818d80153fa" down_revision: Union[str, Sequence[str], None] = "af8e3b2c1d5f" +>>>>>>> upstream/master branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -22,10 +30,15 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### +<<<<<<< HEAD + with op.batch_alter_table('rngdle', schema=None) as batch_op: + batch_op.add_column(sa.Column('badge_count', sa.Integer(), server_default='0', nullable=False)) +======= with op.batch_alter_table("rngdle", schema=None) as batch_op: batch_op.add_column( sa.Column("badge_count", sa.Integer(), server_default="0", nullable=False) ) +>>>>>>> upstream/master # ### end Alembic commands ### @@ -33,7 +46,12 @@ def upgrade() -> None: def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### +<<<<<<< HEAD + with op.batch_alter_table('rngdle', schema=None) as batch_op: + batch_op.drop_column('badge_count') +======= with op.batch_alter_table("rngdle", schema=None) as batch_op: batch_op.drop_column("badge_count") +>>>>>>> upstream/master # ### end Alembic commands ### diff --git a/commands/cogs/rngdle.py b/commands/cogs/rngdle.py index 2d030bd..b66d371 100644 --- a/commands/cogs/rngdle.py +++ b/commands/cogs/rngdle.py @@ -1,5 +1,6 @@ from io import BytesIO import datetime +import asyncio import discord from discord import SlashCommandGroup @@ -13,21 +14,103 @@ LeaderboardGenerator, RNGdleLeaderboardUser, ProfileGenerator, + ServerStatGenerator, + OverallLeaderboardGenerator ) from utils.rngdle import RNGdle as RNGdleAPI +class LeaderboardPaginator(discord.ui.View): + def __init__(self, users_data, generator, caller_index=None, current_page=0, per_page=10, is_ephemeral=False): + super().__init__(timeout=180) + self.users_data = users_data + self.generator = generator + self.caller_index = caller_index + self.current_page = current_page + self.per_page = per_page + self.is_ephemeral = is_ephemeral + + self.prev_btn = discord.ui.Button(label="◀ Précédent", style=discord.ButtonStyle.primary) + self.prev_btn.callback = self.prev_callback + self.add_item(self.prev_btn) + + self.page_btn = discord.ui.Button(style=discord.ButtonStyle.success, disabled=True) + self.add_item(self.page_btn) + + self.next_btn = discord.ui.Button(label="Suivant ▶", style=discord.ButtonStyle.primary) + self.next_btn.callback = self.next_callback + self.add_item(self.next_btn) + + self.update_buttons() + + def update_buttons(self): + max_pages = max(1, (len(self.users_data) + self.per_page - 1) // self.per_page) + self.prev_btn.disabled = self.current_page == 0 + self.next_btn.disabled = self.current_page >= max_pages - 1 + self.page_btn.label = f"Page {self.current_page + 1} / {max_pages}" + + async def prev_callback(self, interaction: discord.Interaction): + await self._handle_pagination(interaction, self.current_page - 1) + + async def next_callback(self, interaction: discord.Interaction): + await self._handle_pagination(interaction, self.current_page + 1) + + async def _handle_pagination(self, interaction: discord.Interaction, target_page: int): + start_idx = target_page * self.per_page + end_idx = start_idx + self.per_page + slice_data = self.users_data[start_idx:end_idx] + + caller_info = None + if self.caller_index is not None and not (start_idx <= self.caller_index < end_idx): + caller_info = { + "rank": self.caller_index + 1, + "data": self.users_data[self.caller_index] + } + + img = await self.generator.generate_leaderboard(slice_data, start_rank=start_idx+1, caller_info=caller_info) + buffer = BytesIO() + img.save(buffer, format="PNG") + buffer.seek(0) + file = discord.File(buffer, filename=f"leaderboard_all_p{target_page}.png") + + if self.is_ephemeral: + self.current_page = target_page + self.update_buttons() + await interaction.response.edit_message(file=file, view=self) + else: + new_view = LeaderboardPaginator( + self.users_data, + self.generator, + caller_index=self.caller_index, + current_page=target_page, + per_page=self.per_page, + is_ephemeral=True + ) + await interaction.response.send_message(file=file, view=new_view, ephemeral=True) class RNGdle(commands.Cog): def __init__(self, bot: discord.Bot): self.bot = bot self.leaderboard_generator = LeaderboardGenerator() self.profile_generator = ProfileGenerator() + self.server_stat_generator = ServerStatGenerator() + self.overall_leaderboard_generator = OverallLeaderboardGenerator() self.rngdle_api = RNGdleAPI() rng_group = SlashCommandGroup(name="rngdle", description="RNGDLE commands") rngdle_admin = SlashCommandGroup(name="rngdle-admin", description="RNGDLE admin commands") + def get_score_tier(self, score: int) -> str: + if score < 0: return "ERROR" + elif score < 2098: return "TRASH" + elif score < 5349: return "COMMON" + elif score < 8642: return "UNCOMMON" + elif score < 20245: return "RARE" + elif score < 33971: return "EPIC" + elif score < 150679: return "ANOMALY" + elif score <= 181186584: return "MYTHIC" + else: return "ERROR" + @rngdle_admin.command(description="Register/Update an RNGDLE user") @discord.default_permissions(administrator=True) async def register( @@ -127,8 +210,7 @@ async def leaderboard(self, ctx: discord.ApplicationContext) -> None: if ctx.guild is None: await ctx.respond("This command can only be used in a server!") return - - # Fetch rolls before accessing them + await rngdle_fetch_with_cooldown() scores = await RNGdleDao.get_today_scores(ctx.guild.id) @@ -155,11 +237,11 @@ async def leaderboard(self, ctx: discord.ApplicationContext) -> None: await ctx.respond(file=file) - @rng_group.command(name="profil", description="Show a RNGdle user profile.") + @rng_group.command(name="profile", description="Show a RNGdle user profile.") async def profil( self, ctx: discord.ApplicationContext, - target: discord.Option( + user: discord.Option( str, "RNGdle username or @mention a Discord user", required=False ) = None, ) -> None: @@ -178,14 +260,14 @@ async def profil( await ctx.respond("Personne n'est enregistré sur ce serveur.", ephemeral=True) return - if not target: + if not user: db_user = next((u for u in registered_users if u.user_id == ctx.author.id), None) if db_user: rngdle_username = db_user.rng_username target_id = ctx.author.id member = ctx.author - elif target.startswith("<@") and target.endswith(">"): - target_id = int(target.strip("<@!>")) + elif user.startswith("<@") and user.endswith(">"): + target_id = int(user.strip("<@!>")) db_user = next((u for u in registered_users if u.user_id == target_id), None) if db_user: rngdle_username = db_user.rng_username @@ -193,9 +275,9 @@ async def profil( self.bot, target_id ) else: - rngdle_username = target + rngdle_username = user db_user = next( - (u for u in registered_users if u.rng_username.lower() == target.lower()), + (u for u in registered_users if u.rng_username.lower() == user.lower()), None, ) if db_user: @@ -240,13 +322,14 @@ async def profil( highest_score = score highest_date = rolled_date lucky_number = num - + if badges > max_badges: max_badges = badges avg_score = int(total_score_sum / total_rolls) if total_rolls > 0 else 0 - + rank = await RNGdleDao.get_server_rank_by_total(target_id, ctx.guild.id) + total_players = len(registered_users) if registered_users else 0 stats_dict = { "total_rolls": total_rolls, @@ -257,6 +340,7 @@ async def profil( "lucky_seed": lucky_number, "max_badges": max_badges, "server_rank": rank, + "total_players": total_players } img = await self.profile_generator.generate_profile(member, rngdle_username, stats_dict) @@ -268,6 +352,180 @@ async def profil( await ctx.respond(file=file) + @rng_group.command(name="server-stats", description="Show RNGdle server stats.") + async def server_stats(self, ctx: discord.ApplicationContext) -> None: + """Show RNGDLE server stats.""" + await ctx.defer() + if ctx.guild is None: + await ctx.respond("This command can only be used in a server!") + return + + registered_users = await RNGdleDao.get_registered_users(ctx.guild.id) + if not registered_users: + await ctx.respond("Personne n'est enregistré sur ce serveur.") + return + + user_map = {u.user_id: u.rng_username for u in registered_users} + + await rngdle_fetch_with_cooldown() + rolls = await RNGdleDao.get_guild_rolls(ctx.guild.id) + + if not rolls: + await ctx.respond("Aucun tirage trouvé pour ce serveur.") + return + + total_rolls = len(rolls) + overall_score = 0 + best_roll = {"score": -1, "user": "", "number": 0, "user_id": None} + worst_roll = {"score": float('inf'), "user": "", "number": 0, "user_id": None} + + rarity_counts = { + "TRASH": 0, "COMMON": 0, "UNCOMMON": 0, + "RARE": 0, "EPIC": 0, "ANOMALY": 0, "MYTHIC": 0 + } + + user_rarity_counts = {} + + for roll in rolls: + score = roll.score + number = roll.number + user_id = roll.user_id + username = user_map.get(user_id, "Unknown") + + overall_score += score + + if score > best_roll["score"]: + best_roll = {"score": score, "user": username, "number": number, "user_id": user_id} + + if score < worst_roll["score"]: + worst_roll = {"score": score, "user": username, "number": number, "user_id": user_id} + + tier = self.get_score_tier(score) + if tier in rarity_counts: + rarity_counts[tier] += 1 + + if user_id: + if user_id not in user_rarity_counts: + user_rarity_counts[user_id] = { + "TRASH": 0, "COMMON": 0, "UNCOMMON": 0, + "RARE": 0, "EPIC": 0, "ANOMALY": 0, "MYTHIC": 0 + } + if tier in user_rarity_counts[user_id]: + user_rarity_counts[user_id][tier] += 1 + + tier_kings = { + "TRASH": {"id": None, "max": 0}, + "COMMON": {"id": None, "max": 0}, + "UNCOMMON": {"id": None, "max": 0}, + "RARE": {"id": None, "max": 0}, + "EPIC": {"id": None, "max": 0}, + "ANOMALY": {"id": None, "max": 0}, + "MYTHIC": {"id": None, "max": 0} + } + + for uid, counts in user_rarity_counts.items(): + for t in tier_kings: + if counts[t] > tier_kings[t]["max"]: + tier_kings[t]["max"] = counts[t] + tier_kings[t]["id"] = uid + + avg_score = int(overall_score / total_rolls) if total_rolls > 0 else 0 + + best_member = None + if best_roll.get("user_id"): + best_member = await get_or_fetch_user(self.bot, int(best_roll["user_id"])) + best_roll["member"] = best_member + + worst_member = None + if worst_roll.get("user_id"): + worst_member = await get_or_fetch_user(self.bot, int(worst_roll["user_id"])) + worst_roll["member"] = worst_member + + tier_members = {} + for t, king in tier_kings.items(): + if king["id"]: + tier_members[t] = await get_or_fetch_user(self.bot, int(king["id"])) + + stats_dict = { + "total_rolls": total_rolls, + "overall_score": overall_score, + "avg_score": avg_score, + "best_roll": best_roll, + "worst_roll": worst_roll, + "rarities": rarity_counts, + "tier_members": tier_members + } + + img = await self.server_stat_generator.generate_server_stat(ctx.guild, stats_dict) + + buffer = BytesIO() + img.save(buffer, format="PNG") + buffer.seek(0) + file = discord.File(fp=buffer, filename="server_stats.png") + + await ctx.respond(file=file) + + @rng_group.command(name="leaderboard-all", description="Show the overall RNGdle leaderboard for the server.") + async def leaderboard_all(self, ctx: discord.ApplicationContext, page: discord.Option(int, "Page number", min_value=1, default=1)): + await ctx.defer() + if ctx.guild is None: + await ctx.respond("This command can only be used in a server!") + return + + leaderboard_rows = await RNGdleDao.get_overall_leaderboard(ctx.guild.id) + + if not leaderboard_rows: + await ctx.respond("Aucun score enregistré sur ce serveur.", ephemeral=True) + return + + registered_users = await RNGdleDao.get_registered_users(ctx.guild.id) + reg_map = {u.user_id: u.rng_username for u in registered_users} + + users_data = [] + caller_index = None + caller_id = ctx.author.id + + for i, row in enumerate(leaderboard_rows): + user_id = row.user_id + total_score = row.total_score + + if user_id == caller_id: + caller_index = i + + member = ctx.guild.get_member(user_id) or await get_or_fetch_user(self.bot, user_id) + rngdle_username = reg_map.get(user_id, "Unknown") + + users_data.append({ + "discord_user": member, + "rngdle_username": rngdle_username, + "total_score": total_score + }) + + per_page = 10 + max_pages = max(1, (len(users_data) + per_page - 1) // per_page) + + target_page = min(page, max_pages) - 1 + + start_idx = target_page * per_page + end_idx = start_idx + per_page + slice_data = users_data[start_idx:end_idx] + + caller_info = None + if caller_index is not None and not (start_idx <= caller_index < end_idx): + caller_info = { + "rank": caller_index + 1, + "data": users_data[caller_index] + } + + img = await self.overall_leaderboard_generator.generate_leaderboard(slice_data, start_rank=start_idx+1, caller_info=caller_info) + + buffer = BytesIO() + img.save(buffer, format="PNG") + buffer.seek(0) + file = discord.File(fp=buffer, filename=f"leaderboard_all_p{target_page}.png") + + view = LeaderboardPaginator(users_data, self.overall_leaderboard_generator, caller_index=caller_index, current_page=target_page, per_page=per_page, is_ephemeral=False) + await ctx.respond(file=file, view=view) def setup(bot): - bot.add_cog(RNGdle(bot)) + bot.add_cog(RNGdle(bot)) \ No newline at end of file diff --git a/utils/database/dao/rngdle.py b/utils/database/dao/rngdle.py index dbda125..4c824fb 100644 --- a/utils/database/dao/rngdle.py +++ b/utils/database/dao/rngdle.py @@ -174,12 +174,31 @@ async def get_server_rank_by_total(user_id: int, guild_id: int) -> int: ) rows = await session.execute(query) leaderboard = rows.all() - for rank, row in enumerate(leaderboard, start=1): if row.user_id == user_id: return rank return 0 + @staticmethod + async def get_guild_rolls(guild_id: int) -> Sequence[RNGdle] | None: + async for session in get_db(): + query = select(RNGdle).filter(RNGdle.guild_id == guild_id) + rows = await session.execute(query) + return rows.scalars().all() + return None + + @staticmethod + async def get_overall_leaderboard(guild_id: int): + async for session in get_db(): + query = ( + select(RNGdle.user_id, func.sum(RNGdle.score).label("total_score")) + .filter(RNGdle.guild_id == guild_id) + .group_by(RNGdle.user_id) + .order_by(func.sum(RNGdle.score).desc()) + ) + rows = await session.execute(query) + return rows.all() + class RNGdleGuildConfigDao: @staticmethod @@ -215,4 +234,4 @@ async def get_all_configured_guilds() -> Sequence[RNGdleGuildConfig]: async for session in get_db(): result = await session.execute(select(RNGdleGuildConfig)) return result.scalars().all() - return [] + return [] \ No newline at end of file diff --git a/utils/image_generator.py b/utils/image_generator.py index 01e63b8..2171e0b 100644 --- a/utils/image_generator.py +++ b/utils/image_generator.py @@ -89,13 +89,13 @@ class LeaderboardGenerator: HEADER_HEIGHT: int = 100 # Slightly lighter header background - BG_COLOR: ColorType = (25, 25, 25) # Dark background - TEXT_COLOR: ColorType = (255, 255, 255) # White text - + BG_COLOR: ColorType = (25, 25, 25) # Dark background + TEXT_COLOR: ColorType = (255, 255, 255) # White text + HEADER_BG_COLOR: ColorType = (50, 50, 50) - ROW_EVEN_COLOR: ColorType = (35, 35, 35) # Even row background - ROW_ODD_COLOR: ColorType = (45, 45, 45) # Odd row background - HIGHLIGHT_COLOR: ColorType = (0, 100, 200) # For "async" button + ROW_EVEN_COLOR: ColorType = (35, 35, 35) # Even row background + ROW_ODD_COLOR: ColorType = (45, 45, 45) # Odd row background + HIGHLIGHT_COLOR: ColorType = (0, 100, 200) # For "async" button def __init__(self): self.font_path: pathlib.Path | None = None @@ -172,7 +172,7 @@ def _get_fitted_text_font( return base_font font = base_font font_path = getattr(font, "path", self.font_path) - font_size = getattr(font, "size", 30) # 30 is regular font size + font_size = getattr(font, "size", 30) # 30 is regular font size while draw.textlength(text, font=font) > max_width and font_size > 1: font_size -= 1 font = ImageFont.truetype(font_path, font_size) @@ -204,7 +204,7 @@ def _draw_fitted( return font = base_font font_path = getattr(font, "path", self.font_path) - font_size = getattr(font, "size", 30) # 30 is regular font size + font_size = getattr(font, "size", 30) # 30 is regular font size while draw.textlength(text, font=font) > max_width and font_size > 1: font_size -= 1 font = ImageFont.truetype(font_path, font_size) @@ -265,7 +265,7 @@ async def generate_leaderboard(self, users: list[LeaderboardUser]): anchor = "lt" if model == RNGdleLeaderboardUser and i >= 2: - # Anchor to the right and fix the x position for the text + # Anchor to the right and fix the x position for the text anchor = "rt" x += model.column_max_widths[i - 2] @@ -378,7 +378,7 @@ async def generate_leaderboard(self, users: list[LeaderboardUser]): draw_func = self._draw_fitted_align_right user = typing.cast(RNGdleLeaderboardUser, user) - if user.column_headers[col_idx] == "Tirage": # RNGdle draw + if user.column_headers[col_idx] == "Tirage": # RNGdle draw # Left pad the number string to be at least 7 chars long for even rendering col_text = f"{col_text:>7}" font = self.font_mono_regular @@ -438,7 +438,7 @@ class ProfileGenerator: def __init__(self): self.base_path: pathlib.Path = pathlib.Path(__file__).parent.resolve() / ".." / "ressources" self._load_fonts() - self._load_images() + self._load_images() def _load_fonts(self): font_file = self.base_path / "font" / "outfit.ttf" @@ -450,6 +450,7 @@ def _load_fonts(self): self.font_tiny = ImageFont.truetype(str(font_file), 18) self.font_rank = ImageFont.truetype(str(font_file), 80) except IOError: + LOGGER.warning("Warning: Could not load specified font for ProfileGenerator.") self.font_title = ImageFont.load_default() self.font_large = ImageFont.load_default() self.font_regular = ImageFont.load_default() @@ -630,3 +631,325 @@ def create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img): mask = mask.resize((avatar_size, avatar_size), Image.Resampling.LANCZOS) avatar_img.putalpha(mask) img.paste(avatar_img, (avatar_x, avatar_y), avatar_img) + +class ServerStatGenerator: + WIDTH: int = 800 + HEIGHT: int = 710 + HEADER_HEIGHT: int = 150 + + BG_COLOR = (25, 25, 25) + TEXT_COLOR = (255, 255, 255) + HEADER_BG_COLOR = (50, 50, 50) + BOX_COLOR = (35, 35, 35) + TITLE_COLOR = (200, 200, 200) + SUBTEXT_COLOR = (170, 170, 170) + + def __init__(self): + self.base_path = pathlib.Path(__file__).parent.resolve() / ".." / "ressources" + self._load_fonts() + + def _load_fonts(self): + font_file = self.base_path / "font" / "outfit.ttf" + try: + self.font_title = ImageFont.truetype(str(font_file), 45) + self.font_large = ImageFont.truetype(str(font_file), 38) + self.font_regular = ImageFont.truetype(str(font_file), 28) + self.font_small = ImageFont.truetype(str(font_file), 22) + self.font_tiny = ImageFont.truetype(str(font_file), 18) + except IOError: + self.font_title = ImageFont.load_default() + self.font_large = ImageFont.load_default() + self.font_regular = ImageFont.load_default() + self.font_small = ImageFont.load_default() + self.font_tiny = ImageFont.load_default() + + def get_tier_info(self, score: int) -> tuple[str, tuple]: + if score < 0: return "ERROR", (255, 41, 41) + elif score < 2098: return "TRASH", (229, 126, 98) + elif score < 5349: return "COMMON", (229, 231, 235) + elif score < 8642: return "UNCOMMON", (94, 233, 181) + elif score < 20245: return "RARE", (142, 197, 255) + elif score < 33971: return "EPIC", (218, 178, 255) + elif score < 150679: return "ANOMALY", (255, 137, 4) + elif score <= 181186584: return "MYTHIC", (253, 165, 213) + else: return "ERROR", (255, 41, 41) + + async def generate_server_stat(self, guild: discord.Guild, stats: dict): + img = Image.new("RGB", (self.WIDTH, self.HEIGHT), self.BG_COLOR) + draw = ImageDraw.Draw(img) + + draw.rectangle([0, 0, self.WIDTH, self.HEADER_HEIGHT], fill=self.HEADER_BG_COLOR) + + icon_x, icon_y, icon_size = 40, 25, 100 + try: + if guild.icon: + icon_data = await guild.icon.read() + icon_img = Image.open(BytesIO(icon_data)).resize((icon_size, icon_size)).convert("RGBA") + else: + raise Exception() + self.create_avatar_mask(icon_img, icon_size, icon_x, icon_y, img) + except Exception: + default_icon = Image.new("RGBA", (icon_size, icon_size), (120, 120, 120, 255)) + self.create_avatar_mask(default_icon, icon_size, icon_x, icon_y, img) + + draw.text((170, 45), "RNGdle - Server Stats", fill=self.TEXT_COLOR, font=self.font_title) + + async def fetch_avatar(member, size): + if member: + try: + data = await member.display_avatar.read() + return Image.open(BytesIO(data)).resize((size, size)).convert("RGBA") + except: + pass + return Image.new("RGBA", (size, size), (120, 120, 120, 255)) + + best_avatar = await fetch_avatar(stats['best_roll'].get('member'), 32) + worst_avatar = await fetch_avatar(stats['worst_roll'].get('member'), 32) + + tier_avatars = {} + for t, member in stats.get('tier_members', {}).items(): + if member: + tier_avatars[t] = await fetch_avatar(member, 24) + + def draw_box(x, y, w, h, title, value, subtext=None, value_color=None, outline_color=None, score_suffix=None, avatar=None): + if value_color is None: + value_color = self.TEXT_COLOR + + if outline_color: + draw.rounded_rectangle([x, y, x + w, y + h], radius=12, fill=self.BOX_COLOR, outline=outline_color, width=2) + else: + draw.rounded_rectangle([x, y, x + w, y + h], radius=12, fill=self.BOX_COLOR) + + draw.text((x + 20, y + 15), title, fill=self.TITLE_COLOR, font=self.font_small) + + if avatar: + av_x = int(x + w - avatar.width - 15) + av_y = int(y + 10) + self.create_avatar_mask(avatar, avatar.width, av_x, av_y, img) + + draw.text((x + 20, y + 45), value, fill=value_color, font=self.font_regular) + + if score_suffix: + val_width = draw.textlength(value + " ", font=self.font_regular) + draw.text((x + 20 + val_width, y + 45), score_suffix, fill=(215, 215, 215), font=self.font_regular) + + if subtext: + draw.text((x + 20, y + 80), subtext, fill=self.SUBTEXT_COLOR, font=self.font_tiny) + + best_color = self.get_tier_info(stats['best_roll']['score'])[1] + worst_color = self.get_tier_info(stats['worst_roll']['score'])[1] + + best_number_str = f"{stats['best_roll']['number']:,}".replace(",", " ") + best_score_str = f"({stats['best_roll']['score']:,} EP)".replace(",", " ") + + worst_number_str = f"{stats['worst_roll']['number']:,}".replace(",", " ") + worst_score_str = f"({stats['worst_roll']['score']:,} EP)".replace(",", " ") + + draw_box(40, 180, 345, 110, "Best Roll OAT", best_number_str, f"by {stats['best_roll']['user']}", value_color=best_color, outline_color=best_color, score_suffix=best_score_str, avatar=best_avatar) + draw_box(415, 180, 345, 110, "Worst Roll OAT", worst_number_str, f"by {stats['worst_roll']['user']}", value_color=worst_color, outline_color=worst_color, score_suffix=worst_score_str, avatar=worst_avatar) + + box_w = 226 + avg_color = self.get_tier_info(stats['avg_score'])[1] + + draw_box(40, 310, box_w, 110, "Total Rolls", f"{stats['total_rolls']:,}".replace(",", " ")) + draw_box(286, 310, box_w, 110, "Average Score", f"{stats['avg_score']:,} EP".replace(",", " "), value_color=avg_color, outline_color=avg_color) + draw_box(532, 310, box_w, 110, "Overall Score", f"{stats['overall_score']:,} EP".replace(",", " ")) + + draw.rounded_rectangle([40, 440, 760, 680], radius=12, fill=self.BOX_COLOR) + draw.text((60, 455), "Tier Breakdown", fill=self.TITLE_COLOR, font=self.font_small) + + tiers = ["MYTHIC", "ANOMALY", "EPIC", "RARE", "UNCOMMON", "COMMON", "TRASH"] + col_x = [80, 440] + start_y = 500 + + for i, tier in enumerate(tiers): + count = stats['rarities'].get(tier, 0) + color = self.get_tier_info_by_name(tier) + + col_index = 0 if i < 4 else 1 + row_index = i if i < 4 else i - 4 + + x_pos = col_x[col_index] + y_pos = start_y + (45 * row_index) + + draw.text((x_pos, y_pos), f"{tier}:", fill=color, font=self.font_small) + + count_str = f"{count:,}".replace(",", " ") + count_width = draw.textlength(count_str, font=self.font_small) + draw.text((x_pos + 220 - count_width, y_pos), count_str, fill=self.TEXT_COLOR, font=self.font_small) + + if tier in tier_avatars: + self.create_avatar_mask(tier_avatars[tier], 24, int(x_pos + 235), int(y_pos - 1), img) + + return img + + def get_tier_info_by_name(self, name: str) -> tuple: + colors = { + "TRASH": (229, 126, 98), + "COMMON": (229, 231, 235), + "UNCOMMON": (94, 233, 181), + "RARE": (142, 197, 255), + "EPIC": (218, 178, 255), + "ANOMALY": (255, 137, 4), + "MYTHIC": (253, 165, 213) + } + return colors.get(name, (255, 255, 255)) + + @staticmethod + def create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img): + mask = Image.new("L", (avatar_size * 4, avatar_size * 4), 0) + draw_mask = ImageDraw.Draw(mask) + draw_mask.ellipse((0, 0, avatar_size * 4, avatar_size * 4), fill=255) + mask = mask.resize((avatar_size, avatar_size), Image.Resampling.LANCZOS) + avatar_img.putalpha(mask) + img.paste(avatar_img, (avatar_x, avatar_y), avatar_img) + +class OverallLeaderboardGenerator: + WIDTH: int = 800 + ROW_HEIGHT: int = 90 + HEADER_HEIGHT: int = 100 + + BG_COLOR = (25, 25, 25) + TEXT_COLOR = (255, 255, 255) + HEADER_BG_COLOR = (50, 50, 50) + ROW_EVEN_COLOR = (35, 35, 35) + ROW_ODD_COLOR = (45, 45, 45) + + def __init__(self): + self.base_path: pathlib.Path = pathlib.Path(__file__).parent.resolve() / ".." / "ressources" + self._load_fonts() + self._load_images() + + def _load_fonts(self): + font_file = self.base_path / "font" / "outfit.ttf" + try: + self.font_header = ImageFont.truetype(str(font_file), 40) + self.font_regular = ImageFont.truetype(str(font_file), 30) + except IOError: + self.font_header = ImageFont.load_default() + self.font_regular = ImageFont.load_default() + + def _load_images(self): + try: + img_dir = self.base_path / "images" + self.PODIUM_BRONZE = Image.open(str(img_dir / "medal_bronze.png")).convert("RGBA").resize((50, 50)) + self.PODIUM_SILVER = Image.open(str(img_dir / "medal_silver.png")).convert("RGBA").resize((50, 50)) + self.PODIUM_GOLD = Image.open(str(img_dir / "medal_gold.png")).convert("RGBA").resize((50, 50)) + except Exception: + self.PODIUM_BRONZE = None + self.PODIUM_SILVER = None + self.PODIUM_GOLD = None + + @staticmethod + def format_short_number(num: int) -> str: + if num >= 1_000_000_000: + formatted = f"{num / 1_000_000_000:.1f}" + return f"{formatted.replace('.0', '')}Md" + elif num >= 1_000_000: + formatted = f"{num / 1_000_000:.1f}" + return f"{formatted.replace('.0', '')}M" + elif num >= 1_000: + formatted = f"{num / 1_000:.1f}" + return f"{formatted.replace('.0', '')}k" + else: + return str(num) + + def _draw_fitted(self, draw: ImageDraw.ImageDraw, text: str, x: float, y: float, max_width: float, base_font, fill, anchor="lt"): + font = base_font + font_path = getattr(font, "path", None) + font_size = getattr(font, "size", 30) + + if font_path: + while draw.textlength(text, font=font) > max_width and font_size > 1: + font_size -= 1 + font = ImageFont.truetype(font_path, font_size) + + draw.text((x, y), text, fill=fill, font=font, anchor=anchor) + + async def _draw_user_row(self, draw, img, data, rank, y_pos, row_color): + draw.rectangle([0, y_pos, self.WIDTH, y_pos + self.ROW_HEIGHT], fill=row_color) + + rank_text_x = 30 + rank_text_y = y_pos + (self.ROW_HEIGHT / 2) - 15 + + if rank == 1 and self.PODIUM_GOLD: + img.paste(self.PODIUM_GOLD, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_GOLD) + elif rank == 2 and self.PODIUM_SILVER: + img.paste(self.PODIUM_SILVER, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_SILVER) + elif rank == 3 and self.PODIUM_BRONZE: + img.paste(self.PODIUM_BRONZE, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_BRONZE) + else: + draw.text((rank_text_x, rank_text_y), str(rank), fill=self.TEXT_COLOR, font=self.font_regular) + + avatar_x = 100 + avatar_y = y_pos + 15 + avatar_size = 60 + + try: + if data['discord_user']: + avatar_data = await data['discord_user'].display_avatar.read() + avatar_img = Image.open(BytesIO(avatar_data)).resize((avatar_size, avatar_size)).convert("RGBA") + else: + raise Exception() + self.create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img) + except Exception: + default_avatar = Image.new("RGBA", (avatar_size, avatar_size), (120, 120, 120, 255)) + self.create_avatar_mask(default_avatar, avatar_size, avatar_x, avatar_y, img) + + username_x = 190 + username_y = y_pos + (self.ROW_HEIGHT / 2) - 15 + display_name = data['discord_user'].name if data['discord_user'] else data['rngdle_username'] + self._draw_fitted(draw, display_name, username_x, username_y, 320, self.font_regular, self.TEXT_COLOR) + + short_score = self.format_short_number(data['total_score']) + score_str = f"{short_score} EP" + + score_x = 770 + score_y = y_pos + (self.ROW_HEIGHT / 2) - 15 + self._draw_fitted(draw, score_str, score_x, score_y, 250, self.font_regular, (251, 251, 251), anchor="rt") + + async def generate_leaderboard(self, users_data: list[dict], start_rank: int = 1, caller_info: dict = None): + GAP = 20 + base_rows = len(users_data) + total_height = self.HEADER_HEIGHT + (base_rows * self.ROW_HEIGHT) + + if caller_info: + total_height += self.ROW_HEIGHT + GAP + + img = Image.new("RGB", (self.WIDTH, total_height), self.BG_COLOR) + draw = ImageDraw.Draw(img) + + draw.rectangle([0, 0, self.WIDTH, self.HEADER_HEIGHT], fill=self.HEADER_BG_COLOR) + + headers = ["Rang", "Pseudo", "Overall Score"] + x_offsets = [15, 190, 770] + anchors = ["lt", "lt", "rt"] + + for i, header in enumerate(headers): + x, y = x_offsets[i], self.HEADER_HEIGHT / 2 - 15 + draw.text((x, y), header, fill=self.TEXT_COLOR, font=self.font_regular, anchor=anchors[i]) + + for index, data in enumerate(users_data): + y_pos = self.HEADER_HEIGHT + (index * self.ROW_HEIGHT) + row_color = self.ROW_EVEN_COLOR if index % 2 == 0 else self.ROW_ODD_COLOR + await self._draw_user_row(draw, img, data, start_rank + index, y_pos, row_color) + + if caller_info: + base_y = self.HEADER_HEIGHT + (base_rows * self.ROW_HEIGHT) + line_y = base_y + (GAP // 2) + + draw.line([(0, line_y), (self.WIDTH, line_y)], fill=(80, 80, 80), width=2) + + caller_y = base_y + GAP + await self._draw_user_row(draw, img, caller_info['data'], caller_info['rank'], caller_y, self.ROW_EVEN_COLOR) + + return img + + @staticmethod + def create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img): + mask = Image.new("L", (avatar_size * 4, avatar_size * 4), 0) + draw_mask = ImageDraw.Draw(mask) + draw_mask.ellipse((0, 0, avatar_size * 4, avatar_size * 4), fill=255) + mask = mask.resize((avatar_size, avatar_size), Image.Resampling.LANCZOS) + avatar_img.putalpha(mask) + img.paste(avatar_img, (avatar_x, avatar_y), avatar_img) diff --git a/utils/rngdle.py b/utils/rngdle.py index 4dfb363..8eb49b5 100644 --- a/utils/rngdle.py +++ b/utils/rngdle.py @@ -165,7 +165,6 @@ class Tier(Enum): MYTHIC = 7 ERROR = 8 - # From dark theme # TIER_TO_COLOR = { # Tier.TRASH: (255, 210, 48), # dark @@ -201,12 +200,20 @@ class Tier(Enum): } +<<<<<<< HEAD +def get_score_tier_from_table(score: int): + if score not in SCORE_TO_PERCENT: + LOGGER.warning(f"RNGdle: unexpected score {score} (not in table).") + # Enable if you prefer to not consider unknown scores + # return Tier.ERROR +======= def get_score_tier_from_compressed_table(score: int): if score < 0: LOGGER.warning(f"RNGdle: unexpected negative score ({score}).") return Tier.ERROR percent = get_score_percent(score) +>>>>>>> upstream/master if 0 <= percent < 1: tier = Tier.TRASH From ee78bec116b2df4095c2accda3350e03c6f72969 Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:19:36 +0100 Subject: [PATCH 02/14] fix: fixed a little merge mistake --- .vscode/settings.json | 4 ++++ .../c818d80153fa_add_badges_to_rngdle.py | 19 ------------------- 2 files changed, 4 insertions(+), 19 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6a742d9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:poetry", + "python-envs.defaultPackageManager": "ms-python.python:poetry" +} \ No newline at end of file diff --git a/alembic/versions/c818d80153fa_add_badges_to_rngdle.py b/alembic/versions/c818d80153fa_add_badges_to_rngdle.py index 91004c7..b0c7957 100644 --- a/alembic/versions/c818d80153fa_add_badges_to_rngdle.py +++ b/alembic/versions/c818d80153fa_add_badges_to_rngdle.py @@ -5,10 +5,6 @@ Create Date: 2026-08-04 18:20:03.233864 """ -<<<<<<< HEAD -======= - ->>>>>>> upstream/master from typing import Sequence, Union from alembic import op @@ -16,13 +12,8 @@ # revision identifiers, used by Alembic. -<<<<<<< HEAD -revision: str = 'c818d80153fa' -down_revision: Union[str, Sequence[str], None] = 'af8e3b2c1d5f' -======= revision: str = "c818d80153fa" down_revision: Union[str, Sequence[str], None] = "af8e3b2c1d5f" ->>>>>>> upstream/master branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -30,15 +21,10 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### -<<<<<<< HEAD - with op.batch_alter_table('rngdle', schema=None) as batch_op: - batch_op.add_column(sa.Column('badge_count', sa.Integer(), server_default='0', nullable=False)) -======= with op.batch_alter_table("rngdle", schema=None) as batch_op: batch_op.add_column( sa.Column("badge_count", sa.Integer(), server_default="0", nullable=False) ) ->>>>>>> upstream/master # ### end Alembic commands ### @@ -46,12 +32,7 @@ def upgrade() -> None: def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### -<<<<<<< HEAD - with op.batch_alter_table('rngdle', schema=None) as batch_op: - batch_op.drop_column('badge_count') -======= with op.batch_alter_table("rngdle", schema=None) as batch_op: batch_op.drop_column("badge_count") ->>>>>>> upstream/master # ### end Alembic commands ### From df068f106139921c45d2ef834bf88bbfe4e3dd6d Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:17:53 +0100 Subject: [PATCH 03/14] refactor: delete .vscode directory --- .vscode/settings.json | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 6a742d9..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "python-envs.defaultEnvManager": "ms-python.python:poetry", - "python-envs.defaultPackageManager": "ms-python.python:poetry" -} \ No newline at end of file From af4384644c5b0d807e9ee764173e69abf1e91c4a Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:19:22 +0100 Subject: [PATCH 04/14] refactor: update utils/image_generator.py Co-authored-by: Alessevan <44125445+Alessevan@users.noreply.github.com> --- utils/image_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/image_generator.py b/utils/image_generator.py index 2171e0b..a013b9d 100644 --- a/utils/image_generator.py +++ b/utils/image_generator.py @@ -438,7 +438,7 @@ class ProfileGenerator: def __init__(self): self.base_path: pathlib.Path = pathlib.Path(__file__).parent.resolve() / ".." / "ressources" self._load_fonts() - self._load_images() + self._load_images() def _load_fonts(self): font_file = self.base_path / "font" / "outfit.ttf" From 9e6df5bf8cfbc4bd2327e7864c2444b9bf1faae0 Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:20:51 +0100 Subject: [PATCH 05/14] refactor: update commands/cogs/rngdle.py ( function name ) Co-authored-by: Alessevan <44125445+Alessevan@users.noreply.github.com> --- commands/cogs/rngdle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/cogs/rngdle.py b/commands/cogs/rngdle.py index b66d371..b81ec20 100644 --- a/commands/cogs/rngdle.py +++ b/commands/cogs/rngdle.py @@ -238,7 +238,7 @@ async def leaderboard(self, ctx: discord.ApplicationContext) -> None: await ctx.respond(file=file) @rng_group.command(name="profile", description="Show a RNGdle user profile.") - async def profil( + async def profile( self, ctx: discord.ApplicationContext, user: discord.Option( From 9ccf9c9f83ad9b1fa3d3c9d071ac3927d9f53e98 Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:30:44 +0100 Subject: [PATCH 06/14] refactor: update commands/cogs/rngdle.py changed pagination system Co-authored-by: Alessevan <44125445+Alessevan@users.noreply.github.com> --- commands/cogs/rngdle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/cogs/rngdle.py b/commands/cogs/rngdle.py index b81ec20..e3d3fd4 100644 --- a/commands/cogs/rngdle.py +++ b/commands/cogs/rngdle.py @@ -45,7 +45,7 @@ def __init__(self, users_data, generator, caller_index=None, current_page=0, per def update_buttons(self): max_pages = max(1, (len(self.users_data) + self.per_page - 1) // self.per_page) self.prev_btn.disabled = self.current_page == 0 - self.next_btn.disabled = self.current_page >= max_pages - 1 + self.next_btn.disabled = self.current_page + 1 >= max_pages self.page_btn.label = f"Page {self.current_page + 1} / {max_pages}" async def prev_callback(self, interaction: discord.Interaction): From 5d90dcfda3bb4ebbb4d7cd88333913e027434303 Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:37:13 +0100 Subject: [PATCH 07/14] refactor: moved all the computations inside the LeaderboardPaginator class --- .vscode/settings.json | 4 -- commands/cogs/rngdle.py | 87 +++++++++++++++++------------------------ 2 files changed, 36 insertions(+), 55 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 6a742d9..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "python-envs.defaultEnvManager": "ms-python.python:poetry", - "python-envs.defaultPackageManager": "ms-python.python:poetry" -} \ No newline at end of file diff --git a/commands/cogs/rngdle.py b/commands/cogs/rngdle.py index b66d371..71a5582 100644 --- a/commands/cogs/rngdle.py +++ b/commands/cogs/rngdle.py @@ -25,9 +25,11 @@ def __init__(self, users_data, generator, caller_index=None, current_page=0, per self.users_data = users_data self.generator = generator self.caller_index = caller_index - self.current_page = current_page self.per_page = per_page self.is_ephemeral = is_ephemeral + + self.max_pages = max(1, (len(self.users_data) + self.per_page - 1) // self.per_page) + self.current_page = max(0, min(current_page, self.max_pages - 1)) self.prev_btn = discord.ui.Button(label="◀ Précédent", style=discord.ButtonStyle.primary) self.prev_btn.callback = self.prev_callback @@ -43,19 +45,12 @@ def __init__(self, users_data, generator, caller_index=None, current_page=0, per self.update_buttons() def update_buttons(self): - max_pages = max(1, (len(self.users_data) + self.per_page - 1) // self.per_page) self.prev_btn.disabled = self.current_page == 0 - self.next_btn.disabled = self.current_page >= max_pages - 1 - self.page_btn.label = f"Page {self.current_page + 1} / {max_pages}" + self.next_btn.disabled = self.current_page + 1 >= self.max_pages + self.page_btn.label = f"Page {self.current_page + 1} / {self.max_pages}" - async def prev_callback(self, interaction: discord.Interaction): - await self._handle_pagination(interaction, self.current_page - 1) - - async def next_callback(self, interaction: discord.Interaction): - await self._handle_pagination(interaction, self.current_page + 1) - - async def _handle_pagination(self, interaction: discord.Interaction, target_page: int): - start_idx = target_page * self.per_page + async def get_page_content(self) -> discord.File: + start_idx = self.current_page * self.per_page end_idx = start_idx + self.per_page slice_data = self.users_data[start_idx:end_idx] @@ -66,25 +61,40 @@ async def _handle_pagination(self, interaction: discord.Interaction, target_page "data": self.users_data[self.caller_index] } - img = await self.generator.generate_leaderboard(slice_data, start_rank=start_idx+1, caller_info=caller_info) + img = await self.generator.generate_leaderboard( + slice_data, + start_rank=start_idx + 1, + caller_info=caller_info + ) + buffer = BytesIO() img.save(buffer, format="PNG") buffer.seek(0) - file = discord.File(buffer, filename=f"leaderboard_all_p{target_page}.png") + return discord.File(fp=buffer, filename=f"leaderboard_all_p{self.current_page}.png") + + async def prev_callback(self, interaction: discord.Interaction): + await self._handle_pagination(interaction, self.current_page - 1) + async def next_callback(self, interaction: discord.Interaction): + await self._handle_pagination(interaction, self.current_page + 1) + + async def _handle_pagination(self, interaction: discord.Interaction, target_page: int): + self.current_page = target_page + if self.is_ephemeral: - self.current_page = target_page self.update_buttons() + file = await self.get_page_content() await interaction.response.edit_message(file=file, view=self) else: new_view = LeaderboardPaginator( self.users_data, self.generator, caller_index=self.caller_index, - current_page=target_page, + current_page=self.current_page, per_page=self.per_page, is_ephemeral=True ) + file = await new_view.get_page_content() await interaction.response.send_message(file=file, view=new_view, ephemeral=True) class RNGdle(commands.Cog): @@ -100,17 +110,6 @@ def __init__(self, bot: discord.Bot): rngdle_admin = SlashCommandGroup(name="rngdle-admin", description="RNGDLE admin commands") - def get_score_tier(self, score: int) -> str: - if score < 0: return "ERROR" - elif score < 2098: return "TRASH" - elif score < 5349: return "COMMON" - elif score < 8642: return "UNCOMMON" - elif score < 20245: return "RARE" - elif score < 33971: return "EPIC" - elif score < 150679: return "ANOMALY" - elif score <= 181186584: return "MYTHIC" - else: return "ERROR" - @rngdle_admin.command(description="Register/Update an RNGDLE user") @discord.default_permissions(administrator=True) async def register( @@ -500,31 +499,17 @@ async def leaderboard_all(self, ctx: discord.ApplicationContext, page: discord.O "rngdle_username": rngdle_username, "total_score": total_score }) - - per_page = 10 - max_pages = max(1, (len(users_data) + per_page - 1) // per_page) + + view = LeaderboardPaginator( + users_data=users_data, + generator=self.overall_leaderboard_generator, + caller_index=caller_index, + current_page=page - 1, + per_page=10, + is_ephemeral=False + ) - target_page = min(page, max_pages) - 1 - - start_idx = target_page * per_page - end_idx = start_idx + per_page - slice_data = users_data[start_idx:end_idx] - - caller_info = None - if caller_index is not None and not (start_idx <= caller_index < end_idx): - caller_info = { - "rank": caller_index + 1, - "data": users_data[caller_index] - } - - img = await self.overall_leaderboard_generator.generate_leaderboard(slice_data, start_rank=start_idx+1, caller_info=caller_info) - - buffer = BytesIO() - img.save(buffer, format="PNG") - buffer.seek(0) - file = discord.File(fp=buffer, filename=f"leaderboard_all_p{target_page}.png") - - view = LeaderboardPaginator(users_data, self.overall_leaderboard_generator, caller_index=caller_index, current_page=target_page, per_page=per_page, is_ephemeral=False) + file = await view.get_page_content() await ctx.respond(file=file, view=view) def setup(bot): From 020a559e37b878f56704b336f9ca7f7192879f6b Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:38:51 +0100 Subject: [PATCH 08/14] refactor: remove duplicated get_tier_color functions --- utils/image_generator.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/utils/image_generator.py b/utils/image_generator.py index 2171e0b..0c2bc76 100644 --- a/utils/image_generator.py +++ b/utils/image_generator.py @@ -663,17 +663,6 @@ def _load_fonts(self): self.font_small = ImageFont.load_default() self.font_tiny = ImageFont.load_default() - def get_tier_info(self, score: int) -> tuple[str, tuple]: - if score < 0: return "ERROR", (255, 41, 41) - elif score < 2098: return "TRASH", (229, 126, 98) - elif score < 5349: return "COMMON", (229, 231, 235) - elif score < 8642: return "UNCOMMON", (94, 233, 181) - elif score < 20245: return "RARE", (142, 197, 255) - elif score < 33971: return "EPIC", (218, 178, 255) - elif score < 150679: return "ANOMALY", (255, 137, 4) - elif score <= 181186584: return "MYTHIC", (253, 165, 213) - else: return "ERROR", (255, 41, 41) - async def generate_server_stat(self, guild: discord.Guild, stats: dict): img = Image.new("RGB", (self.WIDTH, self.HEIGHT), self.BG_COLOR) draw = ImageDraw.Draw(img) @@ -783,18 +772,6 @@ def draw_box(x, y, w, h, title, value, subtext=None, value_color=None, outline_c return img - def get_tier_info_by_name(self, name: str) -> tuple: - colors = { - "TRASH": (229, 126, 98), - "COMMON": (229, 231, 235), - "UNCOMMON": (94, 233, 181), - "RARE": (142, 197, 255), - "EPIC": (218, 178, 255), - "ANOMALY": (255, 137, 4), - "MYTHIC": (253, 165, 213) - } - return colors.get(name, (255, 255, 255)) - @staticmethod def create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img): mask = Image.new("L", (avatar_size * 4, avatar_size * 4), 0) From 155eff9a546459f0c06b4fd9a8d234d831e49497 Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:45:29 +0100 Subject: [PATCH 09/14] fix: fixed merge issue --- utils/rngdle.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/utils/rngdle.py b/utils/rngdle.py index 8eb49b5..2b5b265 100644 --- a/utils/rngdle.py +++ b/utils/rngdle.py @@ -200,20 +200,12 @@ class Tier(Enum): } -<<<<<<< HEAD -def get_score_tier_from_table(score: int): - if score not in SCORE_TO_PERCENT: - LOGGER.warning(f"RNGdle: unexpected score {score} (not in table).") - # Enable if you prefer to not consider unknown scores - # return Tier.ERROR -======= def get_score_tier_from_compressed_table(score: int): if score < 0: LOGGER.warning(f"RNGdle: unexpected negative score ({score}).") return Tier.ERROR percent = get_score_percent(score) ->>>>>>> upstream/master if 0 <= percent < 1: tier = Tier.TRASH From 39919639d77379278345c890c4bc2c11c2c8a25f Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:54:29 +0100 Subject: [PATCH 10/14] refactor: reformat files --- commands/cogs/rngdle.py | 122 +++++++++++------ utils/database/connection.py | 1 - utils/database/dao/rngdle.py | 2 +- utils/image_generator.py | 256 +++++++++++++++++++++++++---------- utils/rngdle.py | 1 + 5 files changed, 263 insertions(+), 119 deletions(-) diff --git a/commands/cogs/rngdle.py b/commands/cogs/rngdle.py index cc880c5..7e9ad86 100644 --- a/commands/cogs/rngdle.py +++ b/commands/cogs/rngdle.py @@ -15,19 +15,28 @@ RNGdleLeaderboardUser, ProfileGenerator, ServerStatGenerator, - OverallLeaderboardGenerator + OverallLeaderboardGenerator, ) from utils.rngdle import RNGdle as RNGdleAPI + class LeaderboardPaginator(discord.ui.View): - def __init__(self, users_data, generator, caller_index=None, current_page=0, per_page=10, is_ephemeral=False): + def __init__( + self, + users_data, + generator, + caller_index=None, + current_page=0, + per_page=10, + is_ephemeral=False, + ): super().__init__(timeout=180) self.users_data = users_data self.generator = generator self.caller_index = caller_index self.per_page = per_page self.is_ephemeral = is_ephemeral - + self.max_pages = max(1, (len(self.users_data) + self.per_page - 1) // self.per_page) self.current_page = max(0, min(current_page, self.max_pages - 1)) @@ -58,15 +67,13 @@ async def get_page_content(self) -> discord.File: if self.caller_index is not None and not (start_idx <= self.caller_index < end_idx): caller_info = { "rank": self.caller_index + 1, - "data": self.users_data[self.caller_index] + "data": self.users_data[self.caller_index], } img = await self.generator.generate_leaderboard( - slice_data, - start_rank=start_idx + 1, - caller_info=caller_info + slice_data, start_rank=start_idx + 1, caller_info=caller_info ) - + buffer = BytesIO() img.save(buffer, format="PNG") buffer.seek(0) @@ -80,23 +87,24 @@ async def next_callback(self, interaction: discord.Interaction): async def _handle_pagination(self, interaction: discord.Interaction, target_page: int): self.current_page = target_page - + if self.is_ephemeral: self.update_buttons() file = await self.get_page_content() await interaction.response.edit_message(file=file, view=self) else: new_view = LeaderboardPaginator( - self.users_data, - self.generator, + self.users_data, + self.generator, caller_index=self.caller_index, - current_page=self.current_page, - per_page=self.per_page, - is_ephemeral=True + current_page=self.current_page, + per_page=self.per_page, + is_ephemeral=True, ) file = await new_view.get_page_content() await interaction.response.send_message(file=file, view=new_view, ephemeral=True) + class RNGdle(commands.Cog): def __init__(self, bot: discord.Bot): self.bot = bot @@ -209,7 +217,7 @@ async def leaderboard(self, ctx: discord.ApplicationContext) -> None: if ctx.guild is None: await ctx.respond("This command can only be used in a server!") return - + await rngdle_fetch_with_cooldown() scores = await RNGdleDao.get_today_scores(ctx.guild.id) @@ -321,12 +329,12 @@ async def profile( highest_score = score highest_date = rolled_date lucky_number = num - + if badges > max_badges: max_badges = badges avg_score = int(total_score_sum / total_rolls) if total_rolls > 0 else 0 - + rank = await RNGdleDao.get_server_rank_by_total(target_id, ctx.guild.id) total_players = len(registered_users) if registered_users else 0 @@ -339,7 +347,7 @@ async def profile( "lucky_seed": lucky_number, "max_badges": max_badges, "server_rank": rank, - "total_players": total_players + "total_players": total_players, } img = await self.profile_generator.generate_profile(member, rngdle_username, stats_dict) @@ -376,11 +384,16 @@ async def server_stats(self, ctx: discord.ApplicationContext) -> None: total_rolls = len(rolls) overall_score = 0 best_roll = {"score": -1, "user": "", "number": 0, "user_id": None} - worst_roll = {"score": float('inf'), "user": "", "number": 0, "user_id": None} - + worst_roll = {"score": float("inf"), "user": "", "number": 0, "user_id": None} + rarity_counts = { - "TRASH": 0, "COMMON": 0, "UNCOMMON": 0, - "RARE": 0, "EPIC": 0, "ANOMALY": 0, "MYTHIC": 0 + "TRASH": 0, + "COMMON": 0, + "UNCOMMON": 0, + "RARE": 0, + "EPIC": 0, + "ANOMALY": 0, + "MYTHIC": 0, } user_rarity_counts = {} @@ -392,12 +405,17 @@ async def server_stats(self, ctx: discord.ApplicationContext) -> None: username = user_map.get(user_id, "Unknown") overall_score += score - + if score > best_roll["score"]: best_roll = {"score": score, "user": username, "number": number, "user_id": user_id} - + if score < worst_roll["score"]: - worst_roll = {"score": score, "user": username, "number": number, "user_id": user_id} + worst_roll = { + "score": score, + "user": username, + "number": number, + "user_id": user_id, + } tier = self.get_score_tier(score) if tier in rarity_counts: @@ -406,8 +424,13 @@ async def server_stats(self, ctx: discord.ApplicationContext) -> None: if user_id: if user_id not in user_rarity_counts: user_rarity_counts[user_id] = { - "TRASH": 0, "COMMON": 0, "UNCOMMON": 0, - "RARE": 0, "EPIC": 0, "ANOMALY": 0, "MYTHIC": 0 + "TRASH": 0, + "COMMON": 0, + "UNCOMMON": 0, + "RARE": 0, + "EPIC": 0, + "ANOMALY": 0, + "MYTHIC": 0, } if tier in user_rarity_counts[user_id]: user_rarity_counts[user_id][tier] += 1 @@ -419,7 +442,7 @@ async def server_stats(self, ctx: discord.ApplicationContext) -> None: "RARE": {"id": None, "max": 0}, "EPIC": {"id": None, "max": 0}, "ANOMALY": {"id": None, "max": 0}, - "MYTHIC": {"id": None, "max": 0} + "MYTHIC": {"id": None, "max": 0}, } for uid, counts in user_rarity_counts.items(): @@ -452,7 +475,7 @@ async def server_stats(self, ctx: discord.ApplicationContext) -> None: "best_roll": best_roll, "worst_roll": worst_roll, "rarities": rarity_counts, - "tier_members": tier_members + "tier_members": tier_members, } img = await self.server_stat_generator.generate_server_stat(ctx.guild, stats_dict) @@ -464,15 +487,21 @@ async def server_stats(self, ctx: discord.ApplicationContext) -> None: await ctx.respond(file=file) - @rng_group.command(name="leaderboard-all", description="Show the overall RNGdle leaderboard for the server.") - async def leaderboard_all(self, ctx: discord.ApplicationContext, page: discord.Option(int, "Page number", min_value=1, default=1)): + @rng_group.command( + name="leaderboard-all", description="Show the overall RNGdle leaderboard for the server." + ) + async def leaderboard_all( + self, + ctx: discord.ApplicationContext, + page: discord.Option(int, "Page number", min_value=1, default=1), + ): await ctx.defer() if ctx.guild is None: await ctx.respond("This command can only be used in a server!") return leaderboard_rows = await RNGdleDao.get_overall_leaderboard(ctx.guild.id) - + if not leaderboard_rows: await ctx.respond("Aucun score enregistré sur ce serveur.", ephemeral=True) return @@ -494,23 +523,26 @@ async def leaderboard_all(self, ctx: discord.ApplicationContext, page: discord.O member = ctx.guild.get_member(user_id) or await get_or_fetch_user(self.bot, user_id) rngdle_username = reg_map.get(user_id, "Unknown") - users_data.append({ - "discord_user": member, - "rngdle_username": rngdle_username, - "total_score": total_score - }) + users_data.append( + { + "discord_user": member, + "rngdle_username": rngdle_username, + "total_score": total_score, + } + ) view = LeaderboardPaginator( - users_data=users_data, - generator=self.overall_leaderboard_generator, - caller_index=caller_index, - current_page=page - 1, - per_page=10, - is_ephemeral=False + users_data=users_data, + generator=self.overall_leaderboard_generator, + caller_index=caller_index, + current_page=page - 1, + per_page=10, + is_ephemeral=False, ) - + file = await view.get_page_content() await ctx.respond(file=file, view=view) + def setup(bot): - bot.add_cog(RNGdle(bot)) \ No newline at end of file + bot.add_cog(RNGdle(bot)) diff --git a/utils/database/connection.py b/utils/database/connection.py index f6839b1..a527672 100644 --- a/utils/database/connection.py +++ b/utils/database/connection.py @@ -3,7 +3,6 @@ from config import DATABASE_PATH - Base = declarative_base() SQLALCHEMY_DATABASE_URL = f"sqlite+aiosqlite:///{DATABASE_PATH}" diff --git a/utils/database/dao/rngdle.py b/utils/database/dao/rngdle.py index 4c824fb..8d78a8f 100644 --- a/utils/database/dao/rngdle.py +++ b/utils/database/dao/rngdle.py @@ -234,4 +234,4 @@ async def get_all_configured_guilds() -> Sequence[RNGdleGuildConfig]: async for session in get_db(): result = await session.execute(select(RNGdleGuildConfig)) return result.scalars().all() - return [] \ No newline at end of file + return [] diff --git a/utils/image_generator.py b/utils/image_generator.py index 1758643..b167985 100644 --- a/utils/image_generator.py +++ b/utils/image_generator.py @@ -89,13 +89,13 @@ class LeaderboardGenerator: HEADER_HEIGHT: int = 100 # Slightly lighter header background - BG_COLOR: ColorType = (25, 25, 25) # Dark background - TEXT_COLOR: ColorType = (255, 255, 255) # White text - + BG_COLOR: ColorType = (25, 25, 25) # Dark background + TEXT_COLOR: ColorType = (255, 255, 255) # White text + HEADER_BG_COLOR: ColorType = (50, 50, 50) - ROW_EVEN_COLOR: ColorType = (35, 35, 35) # Even row background - ROW_ODD_COLOR: ColorType = (45, 45, 45) # Odd row background - HIGHLIGHT_COLOR: ColorType = (0, 100, 200) # For "async" button + ROW_EVEN_COLOR: ColorType = (35, 35, 35) # Even row background + ROW_ODD_COLOR: ColorType = (45, 45, 45) # Odd row background + HIGHLIGHT_COLOR: ColorType = (0, 100, 200) # For "async" button def __init__(self): self.font_path: pathlib.Path | None = None @@ -172,7 +172,7 @@ def _get_fitted_text_font( return base_font font = base_font font_path = getattr(font, "path", self.font_path) - font_size = getattr(font, "size", 30) # 30 is regular font size + font_size = getattr(font, "size", 30) # 30 is regular font size while draw.textlength(text, font=font) > max_width and font_size > 1: font_size -= 1 font = ImageFont.truetype(font_path, font_size) @@ -204,7 +204,7 @@ def _draw_fitted( return font = base_font font_path = getattr(font, "path", self.font_path) - font_size = getattr(font, "size", 30) # 30 is regular font size + font_size = getattr(font, "size", 30) # 30 is regular font size while draw.textlength(text, font=font) > max_width and font_size > 1: font_size -= 1 font = ImageFont.truetype(font_path, font_size) @@ -265,7 +265,7 @@ async def generate_leaderboard(self, users: list[LeaderboardUser]): anchor = "lt" if model == RNGdleLeaderboardUser and i >= 2: - # Anchor to the right and fix the x position for the text + # Anchor to the right and fix the x position for the text anchor = "rt" x += model.column_max_widths[i - 2] @@ -378,7 +378,7 @@ async def generate_leaderboard(self, users: list[LeaderboardUser]): draw_func = self._draw_fitted_align_right user = typing.cast(RNGdleLeaderboardUser, user) - if user.column_headers[col_idx] == "Tirage": # RNGdle draw + if user.column_headers[col_idx] == "Tirage": # RNGdle draw # Left pad the number string to be at least 7 chars long for even rendering col_text = f"{col_text:>7}" font = self.font_mono_regular @@ -632,17 +632,18 @@ def create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img): avatar_img.putalpha(mask) img.paste(avatar_img, (avatar_x, avatar_y), avatar_img) + class ServerStatGenerator: WIDTH: int = 800 - HEIGHT: int = 710 + HEIGHT: int = 710 HEADER_HEIGHT: int = 150 - BG_COLOR = (25, 25, 25) - TEXT_COLOR = (255, 255, 255) - HEADER_BG_COLOR = (50, 50, 50) - BOX_COLOR = (35, 35, 35) - TITLE_COLOR = (200, 200, 200) - SUBTEXT_COLOR = (170, 170, 170) + BG_COLOR = (25, 25, 25) + TEXT_COLOR = (255, 255, 255) + HEADER_BG_COLOR = (50, 50, 50) + BOX_COLOR = (35, 35, 35) + TITLE_COLOR = (200, 200, 200) + SUBTEXT_COLOR = (170, 170, 170) def __init__(self): self.base_path = pathlib.Path(__file__).parent.resolve() / ".." / "ressources" @@ -673,7 +674,9 @@ async def generate_server_stat(self, guild: discord.Guild, stats: dict): try: if guild.icon: icon_data = await guild.icon.read() - icon_img = Image.open(BytesIO(icon_data)).resize((icon_size, icon_size)).convert("RGBA") + icon_img = ( + Image.open(BytesIO(icon_data)).resize((icon_size, icon_size)).convert("RGBA") + ) else: raise Exception() self.create_avatar_mask(icon_img, icon_size, icon_x, icon_y, img) @@ -692,57 +695,120 @@ async def fetch_avatar(member, size): pass return Image.new("RGBA", (size, size), (120, 120, 120, 255)) - best_avatar = await fetch_avatar(stats['best_roll'].get('member'), 32) - worst_avatar = await fetch_avatar(stats['worst_roll'].get('member'), 32) - + best_avatar = await fetch_avatar(stats["best_roll"].get("member"), 32) + worst_avatar = await fetch_avatar(stats["worst_roll"].get("member"), 32) + tier_avatars = {} - for t, member in stats.get('tier_members', {}).items(): + for t, member in stats.get("tier_members", {}).items(): if member: tier_avatars[t] = await fetch_avatar(member, 24) - def draw_box(x, y, w, h, title, value, subtext=None, value_color=None, outline_color=None, score_suffix=None, avatar=None): + def draw_box( + x, + y, + w, + h, + title, + value, + subtext=None, + value_color=None, + outline_color=None, + score_suffix=None, + avatar=None, + ): if value_color is None: value_color = self.TEXT_COLOR - + if outline_color: - draw.rounded_rectangle([x, y, x + w, y + h], radius=12, fill=self.BOX_COLOR, outline=outline_color, width=2) + draw.rounded_rectangle( + [x, y, x + w, y + h], + radius=12, + fill=self.BOX_COLOR, + outline=outline_color, + width=2, + ) else: draw.rounded_rectangle([x, y, x + w, y + h], radius=12, fill=self.BOX_COLOR) - + draw.text((x + 20, y + 15), title, fill=self.TITLE_COLOR, font=self.font_small) - + if avatar: av_x = int(x + w - avatar.width - 15) av_y = int(y + 10) self.create_avatar_mask(avatar, avatar.width, av_x, av_y, img) draw.text((x + 20, y + 45), value, fill=value_color, font=self.font_regular) - + if score_suffix: val_width = draw.textlength(value + " ", font=self.font_regular) - draw.text((x + 20 + val_width, y + 45), score_suffix, fill=(215, 215, 215), font=self.font_regular) - + draw.text( + (x + 20 + val_width, y + 45), + score_suffix, + fill=(215, 215, 215), + font=self.font_regular, + ) + if subtext: draw.text((x + 20, y + 80), subtext, fill=self.SUBTEXT_COLOR, font=self.font_tiny) - best_color = self.get_tier_info(stats['best_roll']['score'])[1] - worst_color = self.get_tier_info(stats['worst_roll']['score'])[1] - + best_color = self.get_tier_info(stats["best_roll"]["score"])[1] + worst_color = self.get_tier_info(stats["worst_roll"]["score"])[1] + best_number_str = f"{stats['best_roll']['number']:,}".replace(",", " ") best_score_str = f"({stats['best_roll']['score']:,} EP)".replace(",", " ") - + worst_number_str = f"{stats['worst_roll']['number']:,}".replace(",", " ") worst_score_str = f"({stats['worst_roll']['score']:,} EP)".replace(",", " ") - draw_box(40, 180, 345, 110, "Best Roll OAT", best_number_str, f"by {stats['best_roll']['user']}", value_color=best_color, outline_color=best_color, score_suffix=best_score_str, avatar=best_avatar) - draw_box(415, 180, 345, 110, "Worst Roll OAT", worst_number_str, f"by {stats['worst_roll']['user']}", value_color=worst_color, outline_color=worst_color, score_suffix=worst_score_str, avatar=worst_avatar) + draw_box( + 40, + 180, + 345, + 110, + "Best Roll OAT", + best_number_str, + f"by {stats['best_roll']['user']}", + value_color=best_color, + outline_color=best_color, + score_suffix=best_score_str, + avatar=best_avatar, + ) + draw_box( + 415, + 180, + 345, + 110, + "Worst Roll OAT", + worst_number_str, + f"by {stats['worst_roll']['user']}", + value_color=worst_color, + outline_color=worst_color, + score_suffix=worst_score_str, + avatar=worst_avatar, + ) box_w = 226 - avg_color = self.get_tier_info(stats['avg_score'])[1] - + avg_color = self.get_tier_info(stats["avg_score"])[1] + draw_box(40, 310, box_w, 110, "Total Rolls", f"{stats['total_rolls']:,}".replace(",", " ")) - draw_box(286, 310, box_w, 110, "Average Score", f"{stats['avg_score']:,} EP".replace(",", " "), value_color=avg_color, outline_color=avg_color) - draw_box(532, 310, box_w, 110, "Overall Score", f"{stats['overall_score']:,} EP".replace(",", " ")) + draw_box( + 286, + 310, + box_w, + 110, + "Average Score", + f"{stats['avg_score']:,} EP".replace(",", " "), + value_color=avg_color, + outline_color=avg_color, + ) + draw_box( + 532, + 310, + box_w, + 110, + "Overall Score", + f"{stats['overall_score']:,} EP".replace(",", " "), + ) draw.rounded_rectangle([40, 440, 760, 680], radius=12, fill=self.BOX_COLOR) draw.text((60, 455), "Tier Breakdown", fill=self.TITLE_COLOR, font=self.font_small) @@ -750,25 +816,32 @@ def draw_box(x, y, w, h, title, value, subtext=None, value_color=None, outline_c tiers = ["MYTHIC", "ANOMALY", "EPIC", "RARE", "UNCOMMON", "COMMON", "TRASH"] col_x = [80, 440] start_y = 500 - + for i, tier in enumerate(tiers): - count = stats['rarities'].get(tier, 0) + count = stats["rarities"].get(tier, 0) color = self.get_tier_info_by_name(tier) - + col_index = 0 if i < 4 else 1 row_index = i if i < 4 else i - 4 - + x_pos = col_x[col_index] y_pos = start_y + (45 * row_index) - + draw.text((x_pos, y_pos), f"{tier}:", fill=color, font=self.font_small) - + count_str = f"{count:,}".replace(",", " ") count_width = draw.textlength(count_str, font=self.font_small) - draw.text((x_pos + 220 - count_width, y_pos), count_str, fill=self.TEXT_COLOR, font=self.font_small) + draw.text( + (x_pos + 220 - count_width, y_pos), + count_str, + fill=self.TEXT_COLOR, + font=self.font_small, + ) if tier in tier_avatars: - self.create_avatar_mask(tier_avatars[tier], 24, int(x_pos + 235), int(y_pos - 1), img) + self.create_avatar_mask( + tier_avatars[tier], 24, int(x_pos + 235), int(y_pos - 1), img + ) return img @@ -781,6 +854,7 @@ def create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img): avatar_img.putalpha(mask) img.paste(avatar_img, (avatar_x, avatar_y), avatar_img) + class OverallLeaderboardGenerator: WIDTH: int = 800 ROW_HEIGHT: int = 90 @@ -809,9 +883,15 @@ def _load_fonts(self): def _load_images(self): try: img_dir = self.base_path / "images" - self.PODIUM_BRONZE = Image.open(str(img_dir / "medal_bronze.png")).convert("RGBA").resize((50, 50)) - self.PODIUM_SILVER = Image.open(str(img_dir / "medal_silver.png")).convert("RGBA").resize((50, 50)) - self.PODIUM_GOLD = Image.open(str(img_dir / "medal_gold.png")).convert("RGBA").resize((50, 50)) + self.PODIUM_BRONZE = ( + Image.open(str(img_dir / "medal_bronze.png")).convert("RGBA").resize((50, 50)) + ) + self.PODIUM_SILVER = ( + Image.open(str(img_dir / "medal_silver.png")).convert("RGBA").resize((50, 50)) + ) + self.PODIUM_GOLD = ( + Image.open(str(img_dir / "medal_gold.png")).convert("RGBA").resize((50, 50)) + ) except Exception: self.PODIUM_BRONZE = None self.PODIUM_SILVER = None @@ -831,16 +911,26 @@ def format_short_number(num: int) -> str: else: return str(num) - def _draw_fitted(self, draw: ImageDraw.ImageDraw, text: str, x: float, y: float, max_width: float, base_font, fill, anchor="lt"): + def _draw_fitted( + self, + draw: ImageDraw.ImageDraw, + text: str, + x: float, + y: float, + max_width: float, + base_font, + fill, + anchor="lt", + ): font = base_font font_path = getattr(font, "path", None) font_size = getattr(font, "size", 30) - + if font_path: while draw.textlength(text, font=font) > max_width and font_size > 1: font_size -= 1 font = ImageFont.truetype(font_path, font_size) - + draw.text((x, y), text, fill=fill, font=font, anchor=anchor) async def _draw_user_row(self, draw, img, data, rank, y_pos, row_color): @@ -852,20 +942,30 @@ async def _draw_user_row(self, draw, img, data, rank, y_pos, row_color): if rank == 1 and self.PODIUM_GOLD: img.paste(self.PODIUM_GOLD, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_GOLD) elif rank == 2 and self.PODIUM_SILVER: - img.paste(self.PODIUM_SILVER, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_SILVER) + img.paste( + self.PODIUM_SILVER, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_SILVER + ) elif rank == 3 and self.PODIUM_BRONZE: - img.paste(self.PODIUM_BRONZE, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_BRONZE) + img.paste( + self.PODIUM_BRONZE, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_BRONZE + ) else: - draw.text((rank_text_x, rank_text_y), str(rank), fill=self.TEXT_COLOR, font=self.font_regular) + draw.text( + (rank_text_x, rank_text_y), str(rank), fill=self.TEXT_COLOR, font=self.font_regular + ) avatar_x = 100 avatar_y = y_pos + 15 avatar_size = 60 - + try: - if data['discord_user']: - avatar_data = await data['discord_user'].display_avatar.read() - avatar_img = Image.open(BytesIO(avatar_data)).resize((avatar_size, avatar_size)).convert("RGBA") + if data["discord_user"]: + avatar_data = await data["discord_user"].display_avatar.read() + avatar_img = ( + Image.open(BytesIO(avatar_data)) + .resize((avatar_size, avatar_size)) + .convert("RGBA") + ) else: raise Exception() self.create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img) @@ -875,21 +975,29 @@ async def _draw_user_row(self, draw, img, data, rank, y_pos, row_color): username_x = 190 username_y = y_pos + (self.ROW_HEIGHT / 2) - 15 - display_name = data['discord_user'].name if data['discord_user'] else data['rngdle_username'] - self._draw_fitted(draw, display_name, username_x, username_y, 320, self.font_regular, self.TEXT_COLOR) + display_name = ( + data["discord_user"].name if data["discord_user"] else data["rngdle_username"] + ) + self._draw_fitted( + draw, display_name, username_x, username_y, 320, self.font_regular, self.TEXT_COLOR + ) - short_score = self.format_short_number(data['total_score']) + short_score = self.format_short_number(data["total_score"]) score_str = f"{short_score} EP" - + score_x = 770 score_y = y_pos + (self.ROW_HEIGHT / 2) - 15 - self._draw_fitted(draw, score_str, score_x, score_y, 250, self.font_regular, (251, 251, 251), anchor="rt") + self._draw_fitted( + draw, score_str, score_x, score_y, 250, self.font_regular, (251, 251, 251), anchor="rt" + ) - async def generate_leaderboard(self, users_data: list[dict], start_rank: int = 1, caller_info: dict = None): + async def generate_leaderboard( + self, users_data: list[dict], start_rank: int = 1, caller_info: dict = None + ): GAP = 20 base_rows = len(users_data) total_height = self.HEADER_HEIGHT + (base_rows * self.ROW_HEIGHT) - + if caller_info: total_height += self.ROW_HEIGHT + GAP @@ -904,7 +1012,9 @@ async def generate_leaderboard(self, users_data: list[dict], start_rank: int = 1 for i, header in enumerate(headers): x, y = x_offsets[i], self.HEADER_HEIGHT / 2 - 15 - draw.text((x, y), header, fill=self.TEXT_COLOR, font=self.font_regular, anchor=anchors[i]) + draw.text( + (x, y), header, fill=self.TEXT_COLOR, font=self.font_regular, anchor=anchors[i] + ) for index, data in enumerate(users_data): y_pos = self.HEADER_HEIGHT + (index * self.ROW_HEIGHT) @@ -914,11 +1024,13 @@ async def generate_leaderboard(self, users_data: list[dict], start_rank: int = 1 if caller_info: base_y = self.HEADER_HEIGHT + (base_rows * self.ROW_HEIGHT) line_y = base_y + (GAP // 2) - + draw.line([(0, line_y), (self.WIDTH, line_y)], fill=(80, 80, 80), width=2) - + caller_y = base_y + GAP - await self._draw_user_row(draw, img, caller_info['data'], caller_info['rank'], caller_y, self.ROW_EVEN_COLOR) + await self._draw_user_row( + draw, img, caller_info["data"], caller_info["rank"], caller_y, self.ROW_EVEN_COLOR + ) return img diff --git a/utils/rngdle.py b/utils/rngdle.py index 2b5b265..4dfb363 100644 --- a/utils/rngdle.py +++ b/utils/rngdle.py @@ -165,6 +165,7 @@ class Tier(Enum): MYTHIC = 7 ERROR = 8 + # From dark theme # TIER_TO_COLOR = { # Tier.TRASH: (255, 210, 48), # dark From 2b66ecae5f69f83e825af11b2f5a742ca5acf948 Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:29:48 +0100 Subject: [PATCH 11/14] fix: fixed some issues (importation, format and total_players) --- commands/cogs/rngdle.py | 3 ++- utils/image_generator.py | 53 ++++++++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/commands/cogs/rngdle.py b/commands/cogs/rngdle.py index 7e9ad86..4f1e3bd 100644 --- a/commands/cogs/rngdle.py +++ b/commands/cogs/rngdle.py @@ -18,6 +18,7 @@ OverallLeaderboardGenerator, ) from utils.rngdle import RNGdle as RNGdleAPI +from utils.rngdle import get_score_tier class LeaderboardPaginator(discord.ui.View): @@ -417,7 +418,7 @@ async def server_stats(self, ctx: discord.ApplicationContext) -> None: "user_id": user_id, } - tier = self.get_score_tier(score) + tier = get_score_tier(score).name if tier in rarity_counts: rarity_counts[tier] += 1 diff --git a/utils/image_generator.py b/utils/image_generator.py index b167985..9e91e32 100644 --- a/utils/image_generator.py +++ b/utils/image_generator.py @@ -500,22 +500,59 @@ async def generate_profile(self, user: discord.User, username: str, stats: dict) draw.text((170, 35), username, fill=self.TEXT_COLOR, font=self.font_title) rank = stats.get("server_rank", 0) + total_players = stats.get("total_players", 0) + if rank > 0: + total_str = f" / {total_players}" + total_width = draw.textlength(total_str, font=self.font_regular) + + sub_y = 70 + icon_y = 15 + if rank == 1 and self.PODIUM_GOLD: - img.paste(self.PODIUM_GOLD, (670, 30), self.PODIUM_GOLD) + img.paste(self.PODIUM_GOLD, (int(760 - total_width - 90), icon_y), self.PODIUM_GOLD) + draw.text( + (760 - total_width, sub_y), + total_str, + fill=self.SUBTEXT_COLOR, + font=self.font_regular, + ) elif rank == 2 and self.PODIUM_SILVER: - img.paste(self.PODIUM_SILVER, (667, 30), self.PODIUM_SILVER) + img.paste( + self.PODIUM_SILVER, (int(760 - total_width - 93), icon_y), self.PODIUM_SILVER + ) + draw.text( + (760 - total_width, sub_y), + total_str, + fill=self.SUBTEXT_COLOR, + font=self.font_regular, + ) elif rank == 3 and self.PODIUM_BRONZE: - img.paste(self.PODIUM_BRONZE, (667, 30), self.PODIUM_BRONZE) + img.paste( + self.PODIUM_BRONZE, (int(760 - total_width - 93), icon_y), self.PODIUM_BRONZE + ) + draw.text( + (760 - total_width, sub_y), + total_str, + fill=self.SUBTEXT_COLOR, + font=self.font_regular, + ) else: rank_str = f"#{rank}" rank_width = draw.textlength(rank_str, font=self.font_rank) + draw.text( - (760 - rank_width, 25), + (760 - total_width - rank_width, 25), rank_str, fill=(150, 150, 150), font=self.font_rank, ) + draw.text( + (760 - total_width, sub_y), + total_str, + fill=self.SUBTEXT_COLOR, + font=self.font_regular, + ) best_roll_y = 180 tier = get_score_tier(stats["highest_score"]) @@ -751,8 +788,8 @@ def draw_box( if subtext: draw.text((x + 20, y + 80), subtext, fill=self.SUBTEXT_COLOR, font=self.font_tiny) - best_color = self.get_tier_info(stats["best_roll"]["score"])[1] - worst_color = self.get_tier_info(stats["worst_roll"]["score"])[1] + best_color = get_tier_color(get_score_tier(stats["best_roll"]["score"])) + worst_color = get_tier_color(get_score_tier(stats["worst_roll"]["score"])) best_number_str = f"{stats['best_roll']['number']:,}".replace(",", " ") best_score_str = f"({stats['best_roll']['score']:,} EP)".replace(",", " ") @@ -788,7 +825,7 @@ def draw_box( ) box_w = 226 - avg_color = self.get_tier_info(stats["avg_score"])[1] + avg_color = get_tier_color(get_score_tier(stats["avg_score"])) draw_box(40, 310, box_w, 110, "Total Rolls", f"{stats['total_rolls']:,}".replace(",", " ")) draw_box( @@ -819,7 +856,7 @@ def draw_box( for i, tier in enumerate(tiers): count = stats["rarities"].get(tier, 0) - color = self.get_tier_info_by_name(tier) + color = get_tier_color(Tier[tier]) col_index = 0 if i < 4 else 1 row_index = i if i < 4 else i - 4 From c983ddd30e0844a27363d5ff69e09099c205d331 Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:29:57 +0100 Subject: [PATCH 12/14] feat: finalized user profile UI by adding worst roll and tier breakdown --- commands/cogs/rngdle.py | 28 +++++++- utils/image_generator.py | 150 ++++++++++++++++++++++++--------------- 2 files changed, 120 insertions(+), 58 deletions(-) diff --git a/commands/cogs/rngdle.py b/commands/cogs/rngdle.py index 4f1e3bd..b533eb9 100644 --- a/commands/cogs/rngdle.py +++ b/commands/cogs/rngdle.py @@ -311,11 +311,24 @@ async def profile( return total_rolls = len(rolls) - highest_score = 0 + highest_score = -1 + lowest_score = float("inf") total_score_sum = 0 lucky_number = 0 + unlucky_number = 0 max_badges = 0 highest_date = None + lowest_date = None + + rarity_counts = { + "MYTHIC": 0, + "ANOMALY": 0, + "EPIC": 0, + "RARE": 0, + "UNCOMMON": 0, + "COMMON": 0, + "TRASH": 0, + } for roll in rolls: score = roll.score @@ -331,9 +344,18 @@ async def profile( highest_date = rolled_date lucky_number = num + if score < lowest_score: + lowest_score = score + lowest_date = rolled_date + unlucky_number = num + if badges > max_badges: max_badges = badges + tier = get_score_tier(score).name + if tier in rarity_counts: + rarity_counts[tier] += 1 + avg_score = int(total_score_sum / total_rolls) if total_rolls > 0 else 0 rank = await RNGdleDao.get_server_rank_by_total(target_id, ctx.guild.id) @@ -346,9 +368,13 @@ async def profile( "highest_score": highest_score, "highest_date": (highest_date.strftime("%d %b %Y") if highest_date else "N/A"), "lucky_seed": lucky_number, + "lowest_score": lowest_score if lowest_score != float("inf") else 0, + "lowest_date": (lowest_date.strftime("%d %b %Y") if lowest_date else "N/A"), + "unlucky_seed": unlucky_number, "max_badges": max_badges, "server_rank": rank, "total_players": total_players, + "rarities": rarity_counts, } img = await self.profile_generator.generate_profile(member, rngdle_username, stats_dict) diff --git a/utils/image_generator.py b/utils/image_generator.py index 9e91e32..c49f850 100644 --- a/utils/image_generator.py +++ b/utils/image_generator.py @@ -425,7 +425,7 @@ def create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img): class ProfileGenerator: WIDTH: int = 800 - HEIGHT: int = 610 + HEIGHT: int = 840 HEADER_HEIGHT: int = 150 BG_COLOR: ColorType = (25, 25, 25) @@ -554,60 +554,18 @@ async def generate_profile(self, user: discord.User, username: str, stats: dict) font=self.font_regular, ) - best_roll_y = 180 - tier = get_score_tier(stats["highest_score"]) - rarity_color = get_tier_color(tier) - - draw.rounded_rectangle( - [40, best_roll_y, 760, best_roll_y + 130], - radius=15, - fill=self.BOX_COLOR, - outline=rarity_color, - width=2, - ) - - lucky_seed_str = f"{stats['lucky_seed']:,}".replace(",", " ") - score_str = f"({stats['highest_score']:,} EP)".replace(",", " ") - - draw.text( - (65, best_roll_y + 15), - "BEST ROLL", - fill=rarity_color, - font=self.font_small, - ) - draw.text( - (65, best_roll_y + 50), - lucky_seed_str, - fill=rarity_color, - font=self.font_large, - ) - - seed_width = draw.textlength(lucky_seed_str + " ", font=self.font_large) - draw.text( - (65 + seed_width, best_roll_y + 50), - score_str, - fill=(215, 215, 215), - font=self.font_large, - ) - - date_str = f"Date : {stats['highest_date']}" - date_width = draw.textlength(date_str, font=self.font_small) - draw.text( - (735 - date_width, best_roll_y + 60), - date_str, - fill=self.SUBTEXT_COLOR, - font=self.font_small, - ) - - def draw_stat_box( + def draw_box( x, y, + w, + h, title, value, subtext=None, value_font=None, outline_color=None, value_color=None, + score_suffix=None, ): if value_font is None: value_font = self.font_regular @@ -616,14 +574,14 @@ def draw_stat_box( if outline_color: draw.rounded_rectangle( - [x, y, x + 350, y + 110], + [x, y, x + w, y + h], radius=12, fill=self.BOX_COLOR, outline=outline_color, width=2, ) else: - draw.rounded_rectangle([x, y, x + 350, y + 110], radius=12, fill=self.BOX_COLOR) + draw.rounded_rectangle([x, y, x + w, y + h], radius=12, fill=self.BOX_COLOR) draw.text( (x + 20, y + 15), @@ -632,31 +590,109 @@ def draw_stat_box( font=self.font_small, ) y_offset = 48 if value_font == self.font_small else 45 - draw.text((x + 20, y + y_offset), value, fill=value_color, font=value_font) + draw.text((x + 20, y + y_offset), str(value), fill=value_color, font=value_font) + + if score_suffix: + val_width = draw.textlength(str(value) + " ", font=value_font) + draw.text( + (x + 20 + val_width, y + y_offset), + score_suffix, + fill=(215, 215, 215), + font=self.font_regular, + ) + if subtext: draw.text( - (x + 20, y + 82), + (x + 20, y + 80), subtext, fill=self.SUBTEXT_COLOR, font=self.font_tiny, ) + best_roll_y = 180 + + best_tier = get_score_tier(stats["highest_score"]) + best_color = get_tier_color(best_tier) + best_seed_str = f"{stats['lucky_seed']:,}".replace(",", " ") + best_score_str = f"({stats['highest_score']:,} EP)".replace(",", " ") + + worst_tier = get_score_tier(stats["lowest_score"]) + worst_color = get_tier_color(worst_tier) + worst_seed_str = f"{stats['unlucky_seed']:,}".replace(",", " ") + worst_score_str = f"({stats['lowest_score']:,} EP)".replace(",", " ") + + draw_box( + 40, + best_roll_y, + 345, + 110, + "Best Roll", + best_seed_str, + subtext=f"Date : {stats['highest_date']}", + value_color=best_color, + outline_color=best_color, + score_suffix=best_score_str, + ) + + draw_box( + 415, + best_roll_y, + 345, + 110, + "Worst Roll", + worst_seed_str, + subtext=f"Date : {stats['lowest_date']}", + value_color=worst_color, + outline_color=worst_color, + score_suffix=worst_score_str, + ) + avg_score_str = f"{stats['avg_score']:,}".replace(",", " ") + " EP" overall_score_str = f"{stats['total_score_sum']:,}".replace(",", " ") + " EP" avg_tier = get_score_tier(stats["avg_score"]) avg_color = get_tier_color(avg_tier) - draw_stat_box(40, 340, "Total Rolls", str(stats["total_rolls"])) - draw_stat_box( - 410, - 340, + draw_box(40, 310, 345, 110, "Total Rolls", str(stats["total_rolls"])) + draw_box( + 415, + 310, + 345, + 110, "Average Score", avg_score_str, outline_color=avg_color, value_color=avg_color, ) - draw_stat_box(40, 470, "Max Badges", f"{stats['max_badges']} badges at once") - draw_stat_box(410, 470, "Overall Score", overall_score_str) + draw_box(40, 440, 345, 110, "Max Badges", f"{stats['max_badges']} badges at once") + draw_box(415, 440, 345, 110, "Overall Score", overall_score_str) + + draw.rounded_rectangle([40, 570, 760, 810], radius=12, fill=self.BOX_COLOR) + draw.text((60, 585), "Tier Breakdown", fill=self.TITLE_COLOR, font=self.font_small) + + tiers = ["MYTHIC", "ANOMALY", "EPIC", "RARE", "UNCOMMON", "COMMON", "TRASH"] + col_x = [80, 440] + start_y = 630 + + for i, tier_name in enumerate(tiers): + count = stats["rarities"].get(tier_name, 0) + color = get_tier_color(Tier[tier_name]) + + col_index = 0 if i < 4 else 1 + row_index = i if i < 4 else i - 4 + + x_pos = col_x[col_index] + y_pos = start_y + (40 * row_index) + + draw.text((x_pos, y_pos), f"{tier_name}:", fill=color, font=self.font_small) + + count_str = f"{count:,}".replace(",", " ") + count_width = draw.textlength(count_str, font=self.font_small) + draw.text( + (x_pos + 220 - count_width, y_pos), + count_str, + fill=self.TEXT_COLOR, + font=self.font_small, + ) return img From a469c5b14e1b8c814cc85a92c0312e09e64fc117 Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:55:02 +0100 Subject: [PATCH 13/14] feat(rngdle): added display of top 3 overlapping avatars in tier breakdown --- commands/cogs/rngdle.py | 36 +++++++++++++++++------------------- utils/image_generator.py | 29 +++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/commands/cogs/rngdle.py b/commands/cogs/rngdle.py index b533eb9..5ed3321 100644 --- a/commands/cogs/rngdle.py +++ b/commands/cogs/rngdle.py @@ -462,21 +462,17 @@ async def server_stats(self, ctx: discord.ApplicationContext) -> None: if tier in user_rarity_counts[user_id]: user_rarity_counts[user_id][tier] += 1 - tier_kings = { - "TRASH": {"id": None, "max": 0}, - "COMMON": {"id": None, "max": 0}, - "UNCOMMON": {"id": None, "max": 0}, - "RARE": {"id": None, "max": 0}, - "EPIC": {"id": None, "max": 0}, - "ANOMALY": {"id": None, "max": 0}, - "MYTHIC": {"id": None, "max": 0}, - } - - for uid, counts in user_rarity_counts.items(): - for t in tier_kings: - if counts[t] > tier_kings[t]["max"]: - tier_kings[t]["max"] = counts[t] - tier_kings[t]["id"] = uid + tiers_list = ["TRASH", "COMMON", "UNCOMMON", "RARE", "EPIC", "ANOMALY", "MYTHIC"] + tier_kings = {t: [] for t in tiers_list} + + for tier in tiers_list: + tier_users = [ + (uid, counts[tier]) + for uid, counts in user_rarity_counts.items() + if counts[tier] > 0 + ] + tier_users.sort(key=lambda x: x[1], reverse=True) + tier_kings[tier] = tier_users[:3] avg_score = int(overall_score / total_rolls) if total_rolls > 0 else 0 @@ -490,10 +486,12 @@ async def server_stats(self, ctx: discord.ApplicationContext) -> None: worst_member = await get_or_fetch_user(self.bot, int(worst_roll["user_id"])) worst_roll["member"] = worst_member - tier_members = {} - for t, king in tier_kings.items(): - if king["id"]: - tier_members[t] = await get_or_fetch_user(self.bot, int(king["id"])) + tier_members = {t: [] for t in tiers_list} + for t, kings in tier_kings.items(): + for uid, count in kings: + member = await get_or_fetch_user(self.bot, int(uid)) + if member: + tier_members[t].append(member) stats_dict = { "total_rolls": total_rolls, diff --git a/utils/image_generator.py b/utils/image_generator.py index c49f850..e28a0bb 100644 --- a/utils/image_generator.py +++ b/utils/image_generator.py @@ -772,9 +772,12 @@ async def fetch_avatar(member, size): worst_avatar = await fetch_avatar(stats["worst_roll"].get("member"), 32) tier_avatars = {} - for t, member in stats.get("tier_members", {}).items(): - if member: - tier_avatars[t] = await fetch_avatar(member, 24) + for t, members_list in stats.get("tier_members", {}).items(): + tier_avatars[t] = [] + for member in members_list: + if member: + avatar = await fetch_avatar(member, 24) + tier_avatars[t].append(avatar) def draw_box( x, @@ -912,9 +915,23 @@ def draw_box( ) if tier in tier_avatars: - self.create_avatar_mask( - tier_avatars[tier], 24, int(x_pos + 235), int(y_pos - 1), img - ) + avatars_to_draw = tier_avatars[tier][:3] + + # On dessine la liste à l'envers pour que le #1 (index 0) soit dessiné en dernier + # et apparaisse donc visuellement "par-dessus" les autres + for idx, avatar in reversed(list(enumerate(avatars_to_draw))): + # Un écart de 14 pixels pour des images de 24px crée un chevauchement de 10px + avatar_x = int(x_pos + 235 + (idx * 14)) + avatar_y = int(y_pos - 1) + + # On dessine un cercle gris foncé (BOX_COLOR) derrière l'avatar pour créer + # une petite bordure qui "coupe" l'avatar en dessous et les détache proprement + draw.ellipse( + [avatar_x - 2, avatar_y - 2, avatar_x + 24 + 2, avatar_y + 24 + 2], + fill=self.BOX_COLOR, + ) + + self.create_avatar_mask(avatar, 24, avatar_x, avatar_y, img) return img From 7fbcc6a78c36302a9fa155cf5f3d9107c81965aa Mon Sep 17 00:00:00 2001 From: FlenderrAX <74816698+FlenderrAX@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:41:27 +0100 Subject: [PATCH 14/14] fix: fixed avatars not showing in the server-stats command --- utils/image_generator.py | 421 +-------------------------------------- 1 file changed, 5 insertions(+), 416 deletions(-) diff --git a/utils/image_generator.py b/utils/image_generator.py index b310524..c851583 100644 --- a/utils/image_generator.py +++ b/utils/image_generator.py @@ -917,15 +917,15 @@ def draw_box( if tier in tier_avatars: avatars_to_draw = tier_avatars[tier][:3] - # On dessine la liste à l'envers pour que le #1 (index 0) soit dessiné en dernier - # et apparaisse donc visuellement "par-dessus" les autres + # We draw the list in reverse order so that #1 (index 0) is drawn last + # and thus appears visually "on top" of the others for idx, avatar in reversed(list(enumerate(avatars_to_draw))): - # Un écart de 14 pixels pour des images de 24px crée un chevauchement de 10px + # A 14-pixel difference for 24-pixel images results in a 10-pixel overlap avatar_x = int(x_pos + 235 + (idx * 14)) avatar_y = int(y_pos - 1) - # On dessine un cercle gris foncé (BOX_COLOR) derrière l'avatar pour créer - # une petite bordure qui "coupe" l'avatar en dessous et les détache proprement + # Draw a dark gray circle (BOX_COLOR) behind the avatar to create + # a small border that "cuts off" the bottom of the avatar and separates them neatly draw.ellipse( [avatar_x - 2, avatar_y - 2, avatar_x + 24 + 2, avatar_y + 24 + 2], fill=self.BOX_COLOR, @@ -1132,414 +1132,3 @@ def create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img): mask = mask.resize((avatar_size, avatar_size), Image.Resampling.LANCZOS) avatar_img.putalpha(mask) img.paste(avatar_img, (avatar_x, avatar_y), avatar_img) - - -class ServerStatGenerator: - WIDTH: int = 800 - HEIGHT: int = 710 - HEADER_HEIGHT: int = 150 - - BG_COLOR = (25, 25, 25) - TEXT_COLOR = (255, 255, 255) - HEADER_BG_COLOR = (50, 50, 50) - BOX_COLOR = (35, 35, 35) - TITLE_COLOR = (200, 200, 200) - SUBTEXT_COLOR = (170, 170, 170) - - def __init__(self): - self.base_path = pathlib.Path(__file__).parent.resolve() / ".." / "ressources" - self._load_fonts() - - def _load_fonts(self): - font_file = self.base_path / "font" / "outfit.ttf" - try: - self.font_title = ImageFont.truetype(str(font_file), 45) - self.font_large = ImageFont.truetype(str(font_file), 38) - self.font_regular = ImageFont.truetype(str(font_file), 28) - self.font_small = ImageFont.truetype(str(font_file), 22) - self.font_tiny = ImageFont.truetype(str(font_file), 18) - except IOError: - self.font_title = ImageFont.load_default() - self.font_large = ImageFont.load_default() - self.font_regular = ImageFont.load_default() - self.font_small = ImageFont.load_default() - self.font_tiny = ImageFont.load_default() - - async def generate_server_stat(self, guild: discord.Guild, stats: dict): - img = Image.new("RGB", (self.WIDTH, self.HEIGHT), self.BG_COLOR) - draw = ImageDraw.Draw(img) - - draw.rectangle([0, 0, self.WIDTH, self.HEADER_HEIGHT], fill=self.HEADER_BG_COLOR) - - icon_x, icon_y, icon_size = 40, 25, 100 - try: - if guild.icon: - icon_data = await guild.icon.read() - icon_img = ( - Image.open(BytesIO(icon_data)).resize((icon_size, icon_size)).convert("RGBA") - ) - else: - raise Exception() - self.create_avatar_mask(icon_img, icon_size, icon_x, icon_y, img) - except Exception: - default_icon = Image.new("RGBA", (icon_size, icon_size), (120, 120, 120, 255)) - self.create_avatar_mask(default_icon, icon_size, icon_x, icon_y, img) - - draw.text((170, 45), "RNGdle - Server Stats", fill=self.TEXT_COLOR, font=self.font_title) - - async def fetch_avatar(member, size): - if member: - try: - data = await member.display_avatar.read() - return Image.open(BytesIO(data)).resize((size, size)).convert("RGBA") - except: - pass - return Image.new("RGBA", (size, size), (120, 120, 120, 255)) - - best_avatar = await fetch_avatar(stats["best_roll"].get("member"), 32) - worst_avatar = await fetch_avatar(stats["worst_roll"].get("member"), 32) - - tier_avatars = {} - for t, member in stats.get("tier_members", {}).items(): - if member: - tier_avatars[t] = await fetch_avatar(member, 24) - - def draw_box( - x, - y, - w, - h, - title, - value, - subtext=None, - value_color=None, - outline_color=None, - score_suffix=None, - avatar=None, - ): - if value_color is None: - value_color = self.TEXT_COLOR - - if outline_color: - draw.rounded_rectangle( - [x, y, x + w, y + h], - radius=12, - fill=self.BOX_COLOR, - outline=outline_color, - width=2, - ) - else: - draw.rounded_rectangle([x, y, x + w, y + h], radius=12, fill=self.BOX_COLOR) - - draw.text((x + 20, y + 15), title, fill=self.TITLE_COLOR, font=self.font_small) - - if avatar: - av_x = int(x + w - avatar.width - 15) - av_y = int(y + 10) - self.create_avatar_mask(avatar, avatar.width, av_x, av_y, img) - - draw.text((x + 20, y + 45), value, fill=value_color, font=self.font_regular) - - if score_suffix: - val_width = draw.textlength(value + " ", font=self.font_regular) - draw.text( - (x + 20 + val_width, y + 45), - score_suffix, - fill=(215, 215, 215), - font=self.font_regular, - ) - - if subtext: - draw.text((x + 20, y + 80), subtext, fill=self.SUBTEXT_COLOR, font=self.font_tiny) - - best_color = get_tier_color(get_score_tier(stats["best_roll"]["score"])) - worst_color = get_tier_color(get_score_tier(stats["worst_roll"]["score"])) - - best_number_str = f"{stats['best_roll']['number']:,}".replace(",", " ") - best_score_str = f"({stats['best_roll']['score']:,} EP)".replace(",", " ") - - worst_number_str = f"{stats['worst_roll']['number']:,}".replace(",", " ") - worst_score_str = f"({stats['worst_roll']['score']:,} EP)".replace(",", " ") - - draw_box( - 40, - 180, - 345, - 110, - "Best Roll OAT", - best_number_str, - f"by {stats['best_roll']['user']}", - value_color=best_color, - outline_color=best_color, - score_suffix=best_score_str, - avatar=best_avatar, - ) - draw_box( - 415, - 180, - 345, - 110, - "Worst Roll OAT", - worst_number_str, - f"by {stats['worst_roll']['user']}", - value_color=worst_color, - outline_color=worst_color, - score_suffix=worst_score_str, - avatar=worst_avatar, - ) - - box_w = 226 - avg_color = get_tier_color(get_score_tier(stats["avg_score"])) - - draw_box(40, 310, box_w, 110, "Total Rolls", f"{stats['total_rolls']:,}".replace(",", " ")) - draw_box( - 286, - 310, - box_w, - 110, - "Average Score", - f"{stats['avg_score']:,} EP".replace(",", " "), - value_color=avg_color, - outline_color=avg_color, - ) - draw_box( - 532, - 310, - box_w, - 110, - "Overall Score", - f"{stats['overall_score']:,} EP".replace(",", " "), - ) - - draw.rounded_rectangle([40, 440, 760, 680], radius=12, fill=self.BOX_COLOR) - draw.text((60, 455), "Tier Breakdown", fill=self.TITLE_COLOR, font=self.font_small) - - tiers = ["MYTHIC", "ANOMALY", "EPIC", "RARE", "UNCOMMON", "COMMON", "TRASH"] - col_x = [80, 440] - start_y = 500 - - for i, tier in enumerate(tiers): - count = stats["rarities"].get(tier, 0) - color = get_tier_color(Tier[tier]) - - col_index = 0 if i < 4 else 1 - row_index = i if i < 4 else i - 4 - - x_pos = col_x[col_index] - y_pos = start_y + (45 * row_index) - - draw.text((x_pos, y_pos), f"{tier}:", fill=color, font=self.font_small) - - count_str = f"{count:,}".replace(",", " ") - count_width = draw.textlength(count_str, font=self.font_small) - draw.text( - (x_pos + 220 - count_width, y_pos), - count_str, - fill=self.TEXT_COLOR, - font=self.font_small, - ) - - if tier in tier_avatars: - self.create_avatar_mask( - tier_avatars[tier], 24, int(x_pos + 235), int(y_pos - 1), img - ) - - return img - - @staticmethod - def create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img): - mask = Image.new("L", (avatar_size * 4, avatar_size * 4), 0) - draw_mask = ImageDraw.Draw(mask) - draw_mask.ellipse((0, 0, avatar_size * 4, avatar_size * 4), fill=255) - mask = mask.resize((avatar_size, avatar_size), Image.Resampling.LANCZOS) - avatar_img.putalpha(mask) - img.paste(avatar_img, (avatar_x, avatar_y), avatar_img) - - -class OverallLeaderboardGenerator: - WIDTH: int = 800 - ROW_HEIGHT: int = 90 - HEADER_HEIGHT: int = 100 - - BG_COLOR = (25, 25, 25) - TEXT_COLOR = (255, 255, 255) - HEADER_BG_COLOR = (50, 50, 50) - ROW_EVEN_COLOR = (35, 35, 35) - ROW_ODD_COLOR = (45, 45, 45) - - def __init__(self): - self.base_path: pathlib.Path = pathlib.Path(__file__).parent.resolve() / ".." / "ressources" - self._load_fonts() - self._load_images() - - def _load_fonts(self): - font_file = self.base_path / "font" / "outfit.ttf" - try: - self.font_header = ImageFont.truetype(str(font_file), 40) - self.font_regular = ImageFont.truetype(str(font_file), 30) - except IOError: - self.font_header = ImageFont.load_default() - self.font_regular = ImageFont.load_default() - - def _load_images(self): - try: - img_dir = self.base_path / "images" - self.PODIUM_BRONZE = ( - Image.open(str(img_dir / "medal_bronze.png")).convert("RGBA").resize((50, 50)) - ) - self.PODIUM_SILVER = ( - Image.open(str(img_dir / "medal_silver.png")).convert("RGBA").resize((50, 50)) - ) - self.PODIUM_GOLD = ( - Image.open(str(img_dir / "medal_gold.png")).convert("RGBA").resize((50, 50)) - ) - except Exception: - self.PODIUM_BRONZE = None - self.PODIUM_SILVER = None - self.PODIUM_GOLD = None - - @staticmethod - def format_short_number(num: int) -> str: - if num >= 1_000_000_000: - formatted = f"{num / 1_000_000_000:.1f}" - return f"{formatted.replace('.0', '')}Md" - elif num >= 1_000_000: - formatted = f"{num / 1_000_000:.1f}" - return f"{formatted.replace('.0', '')}M" - elif num >= 1_000: - formatted = f"{num / 1_000:.1f}" - return f"{formatted.replace('.0', '')}k" - else: - return str(num) - - def _draw_fitted( - self, - draw: ImageDraw.ImageDraw, - text: str, - x: float, - y: float, - max_width: float, - base_font, - fill, - anchor="lt", - ): - font = base_font - font_path = getattr(font, "path", None) - font_size = getattr(font, "size", 30) - - if font_path: - while draw.textlength(text, font=font) > max_width and font_size > 1: - font_size -= 1 - font = ImageFont.truetype(font_path, font_size) - - draw.text((x, y), text, fill=fill, font=font, anchor=anchor) - - async def _draw_user_row(self, draw, img, data, rank, y_pos, row_color): - draw.rectangle([0, y_pos, self.WIDTH, y_pos + self.ROW_HEIGHT], fill=row_color) - - rank_text_x = 30 - rank_text_y = y_pos + (self.ROW_HEIGHT / 2) - 15 - - if rank == 1 and self.PODIUM_GOLD: - img.paste(self.PODIUM_GOLD, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_GOLD) - elif rank == 2 and self.PODIUM_SILVER: - img.paste( - self.PODIUM_SILVER, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_SILVER - ) - elif rank == 3 and self.PODIUM_BRONZE: - img.paste( - self.PODIUM_BRONZE, (rank_text_x - 15, int(rank_text_y - 10)), self.PODIUM_BRONZE - ) - else: - draw.text( - (rank_text_x, rank_text_y), str(rank), fill=self.TEXT_COLOR, font=self.font_regular - ) - - avatar_x = 100 - avatar_y = y_pos + 15 - avatar_size = 60 - - try: - if data["discord_user"]: - avatar_data = await data["discord_user"].display_avatar.read() - avatar_img = ( - Image.open(BytesIO(avatar_data)) - .resize((avatar_size, avatar_size)) - .convert("RGBA") - ) - else: - raise Exception() - self.create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img) - except Exception: - default_avatar = Image.new("RGBA", (avatar_size, avatar_size), (120, 120, 120, 255)) - self.create_avatar_mask(default_avatar, avatar_size, avatar_x, avatar_y, img) - - username_x = 190 - username_y = y_pos + (self.ROW_HEIGHT / 2) - 15 - display_name = ( - data["discord_user"].name if data["discord_user"] else data["rngdle_username"] - ) - self._draw_fitted( - draw, display_name, username_x, username_y, 320, self.font_regular, self.TEXT_COLOR - ) - - short_score = self.format_short_number(data["total_score"]) - score_str = f"{short_score} EP" - - score_x = 770 - score_y = y_pos + (self.ROW_HEIGHT / 2) - 15 - self._draw_fitted( - draw, score_str, score_x, score_y, 250, self.font_regular, (251, 251, 251), anchor="rt" - ) - - async def generate_leaderboard( - self, users_data: list[dict], start_rank: int = 1, caller_info: dict = None - ): - GAP = 20 - base_rows = len(users_data) - total_height = self.HEADER_HEIGHT + (base_rows * self.ROW_HEIGHT) - - if caller_info: - total_height += self.ROW_HEIGHT + GAP - - img = Image.new("RGB", (self.WIDTH, total_height), self.BG_COLOR) - draw = ImageDraw.Draw(img) - - draw.rectangle([0, 0, self.WIDTH, self.HEADER_HEIGHT], fill=self.HEADER_BG_COLOR) - - headers = ["Rang", "Pseudo", "Overall Score"] - x_offsets = [15, 190, 770] - anchors = ["lt", "lt", "rt"] - - for i, header in enumerate(headers): - x, y = x_offsets[i], self.HEADER_HEIGHT / 2 - 15 - draw.text( - (x, y), header, fill=self.TEXT_COLOR, font=self.font_regular, anchor=anchors[i] - ) - - for index, data in enumerate(users_data): - y_pos = self.HEADER_HEIGHT + (index * self.ROW_HEIGHT) - row_color = self.ROW_EVEN_COLOR if index % 2 == 0 else self.ROW_ODD_COLOR - await self._draw_user_row(draw, img, data, start_rank + index, y_pos, row_color) - - if caller_info: - base_y = self.HEADER_HEIGHT + (base_rows * self.ROW_HEIGHT) - line_y = base_y + (GAP // 2) - - draw.line([(0, line_y), (self.WIDTH, line_y)], fill=(80, 80, 80), width=2) - - caller_y = base_y + GAP - await self._draw_user_row( - draw, img, caller_info["data"], caller_info["rank"], caller_y, self.ROW_EVEN_COLOR - ) - - return img - - @staticmethod - def create_avatar_mask(avatar_img, avatar_size, avatar_x, avatar_y, img): - mask = Image.new("L", (avatar_size * 4, avatar_size * 4), 0) - draw_mask = ImageDraw.Draw(mask) - draw_mask.ellipse((0, 0, avatar_size * 4, avatar_size * 4), fill=255) - mask = mask.resize((avatar_size, avatar_size), Image.Resampling.LANCZOS) - avatar_img.putalpha(mask) - img.paste(avatar_img, (avatar_x, avatar_y), avatar_img)