diff --git a/CHANGELOG.md b/CHANGELOG.md index 1074488..3c3ef72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to Codex are documented here. The format is based on ## [Unreleased] ### Added +- **Ladder position in leveling** — `/rank` now shows your **#position** on the + server, and `/leaderboard` appends your own rank when you're outside the top 10. - **Cancel reminders** — `/reminders` now shows a dropdown to cancel a pending reminder (previously you could list them but never remove one). - **Configurable welcome/goodbye channel** — `/welcome set|disable|status` diff --git a/cogs/social.py b/cogs/social.py index 7ada53c..483ac91 100644 --- a/cogs/social.py +++ b/cogs/social.py @@ -22,6 +22,16 @@ def xp_for_level(level: int) -> int: return 5 * level**2 + 50 * level + 100 +async def rank_position(db: aiosqlite.Connection, guild_id: int, level: int, xp: int) -> int: + """1-based ladder position: how many members rank strictly above (level, xp), + 1.""" + cursor = await db.execute( + "SELECT COUNT(*) FROM levels WHERE guild_id=? AND (level > ? OR (level = ? AND xp > ?))", + (guild_id, level, level, xp), + ) + row = await cursor.fetchone() + return (row[0] if row else 0) + 1 + + class Social(commands.Cog): def __init__(self, bot: commands.Bot) -> None: self.bot = bot @@ -84,9 +94,15 @@ async def rank(self, ctx: commands.Context, member: discord.Member | None = None (ctx.guild.id, member.id), ) row = await cursor.fetchone() - xp, level = row if row else (0, 0) + if row is not None: + xp, level = row + position = await rank_position(db, ctx.guild.id, level, xp) + else: + xp, level, position = 0, 0, None embed = discord.Embed(title=f"{member.display_name}'s Rank", color=config.COLOR) embed.set_thumbnail(url=member.display_avatar.url) + if position is not None: + embed.add_field(name="Rank", value=f"#{position}") embed.add_field(name="Level", value=str(level)) embed.add_field(name="XP", value=f"{xp} / {xp_for_level(level)}") await ctx.send(embed=embed) @@ -102,12 +118,26 @@ async def leaderboard(self, ctx: commands.Context) -> None: (ctx.guild.id,), ) rows = await cursor.fetchall() - if not rows: - await ctx.send("No XP earned yet — start chatting!", ephemeral=True) - return - lines = [ - f"**{i}.** <@{uid}> — level {lvl} ({xp} XP)" for i, (uid, lvl, xp) in enumerate(rows, 1) - ] + if not rows: + await ctx.send("No XP earned yet — start chatting!", ephemeral=True) + return + lines = [ + f"**{i}.** <@{uid}> — level {lvl} ({xp} XP)" + for i, (uid, lvl, xp) in enumerate(rows, 1) + ] + # If the caller isn't in the top 10, show where they stand. + if ctx.author.id not in {uid for uid, _, _ in rows}: + cursor = await db.execute( + "SELECT level, xp FROM levels WHERE guild_id=? AND user_id=?", + (ctx.guild.id, ctx.author.id), + ) + me = await cursor.fetchone() + if me is not None: + position = await rank_position(db, ctx.guild.id, me[0], me[1]) + lines.append( + f"\n**{position}.** {ctx.author.mention} — " + f"level {me[0]} ({me[1]} XP) *(you)*" + ) embed = discord.Embed( title="🏆 Leaderboard", description="\n".join(lines), color=config.COLOR ) diff --git a/tests/test_rank_position.py b/tests/test_rank_position.py new file mode 100644 index 0000000..4bb4a54 --- /dev/null +++ b/tests/test_rank_position.py @@ -0,0 +1,38 @@ +"""Tests for social.rank_position — 1-based ladder position from (level, xp).""" + +from __future__ import annotations + +import aiosqlite + +import config +from cogs.social import rank_position +from core.database import init_db + + +async def _seed( + db: aiosqlite.Connection, guild_id: int, members: list[tuple[int, int, int]] +) -> None: + for user_id, level, xp in members: + await db.execute( + "INSERT INTO levels (guild_id, user_id, level, xp) VALUES (?,?,?,?)", + (guild_id, user_id, level, xp), + ) + await db.commit() + + +async def test_positions(tmp_path, monkeypatch) -> None: + db_path = str(tmp_path / "ranks.db") + monkeypatch.setattr(config, "DB_PATH", db_path) + await init_db() + async with aiosqlite.connect(db_path) as db: + # (user, level, xp): user 1 top, then 2, then 3 + await _seed(db, 100, [(1, 5, 50), (2, 5, 10), (3, 3, 90)]) + assert await rank_position(db, 100, 5, 50) == 1 # highest + assert await rank_position(db, 100, 5, 10) == 2 # same level, less xp + assert await rank_position(db, 100, 3, 90) == 3 # lower level + # a brand-new member below everyone + assert await rank_position(db, 100, 0, 0) == 4 + # a hypothetical score above everyone + assert await rank_position(db, 100, 9, 0) == 1 + # different guild is isolated + assert await rank_position(db, 999, 0, 0) == 1