Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
44 changes: 37 additions & 7 deletions cogs/social.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
)
Expand Down
38 changes: 38 additions & 0 deletions tests/test_rank_position.py
Original file line number Diff line number Diff line change
@@ -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