From e6d42c1aa798f6276523f61306a4bcefe1f0ec27 Mon Sep 17 00:00:00 2001 From: Perl404 Date: Fri, 1 May 2026 01:44:41 +0500 Subject: [PATCH] Awarn and leaderboard improve --- src/spesobot/cogs/awarns.py | 47 ++++-- src/spesobot/cogs/departments.py | 47 +++++- src/spesobot/cogs/points.py | 242 +++++++++++++++++++++++++++---- src/spesobot/db.py | 224 +++++++++++++++++++++++++--- src/spesobot/format.py | 15 +- 5 files changed, 508 insertions(+), 67 deletions(-) diff --git a/src/spesobot/cogs/awarns.py b/src/spesobot/cogs/awarns.py index 2472cc8..64e97fa 100644 --- a/src/spesobot/cogs/awarns.py +++ b/src/spesobot/cogs/awarns.py @@ -140,6 +140,7 @@ async def _send_dm( reason: str, new_total: float, is_unawarn: bool, + max_awarn_weight: float = 3.0, ) -> None: try: delta_sign = "−" if is_unawarn else "+" @@ -155,7 +156,7 @@ async def _send_dm( ) dm.add_field( name=f"Текущий вес ({department_name})", - value=f"`{fmt_weight(new_total)}` {weight_bar(new_total)}", + value=f"`{fmt_weight(new_total)}` {weight_bar(new_total, ceiling=max_awarn_weight)}", inline=False, ) if reason: @@ -242,12 +243,13 @@ async def give( reason=reason, ) new_total = await self.db.get_awarn_total(user.id, dept.id) + max_w = dept.max_awarn_weight # 1) Issuer - ephemeral confirmation card. await interaction.response.send_message( embed=self._build_action_embed( title=f"{AWARN_ICON} Варн выдан", - color=severity_color(new_total), + color=severity_color(new_total, max_w), target=user, issuer=interaction.user, department_name=dept.name, @@ -258,6 +260,7 @@ async def give( new_total=new_total, is_unawarn=False, guild=guild, + max_awarn_weight=max_w, ), ephemeral=True, ) @@ -266,7 +269,7 @@ async def give( guild, self._build_action_embed( title=f"{AWARN_ICON} Варн · {dept.name}", - color=severity_color(new_total), + color=severity_color(new_total, max_w), target=user, issuer=interaction.user, department_name=dept.name, @@ -277,6 +280,7 @@ async def give( new_total=new_total, is_unawarn=False, guild=guild, + max_awarn_weight=max_w, ), department_id=dept.id, ) @@ -285,7 +289,7 @@ async def give( guild, self._build_public_embed( title=f"{AWARN_ICON} Варн · {dept.name}", - color=severity_color(new_total), + color=severity_color(new_total, max_w), target=user, issuer=interaction.user, department_name=dept.name, @@ -296,13 +300,14 @@ async def give( new_total=new_total, is_unawarn=False, guild=guild, + max_awarn_weight=max_w, ), ) # 4) DM the recipient. await self._send_dm( user, title=f"{AWARN_ICON} Вам выдан варн", - color=severity_color(new_total), + color=severity_color(new_total, max_w), guild=guild, department_name=dept.name, warn_label=label, @@ -311,6 +316,7 @@ async def give( reason=reason, new_total=new_total, is_unawarn=False, + max_awarn_weight=max_w, ) # ---- /awarn remove -------------------------------------------------- @@ -419,6 +425,7 @@ async def remove( ) return new_total = value + max_w = dept.max_awarn_weight await interaction.response.send_message( embed=self._build_action_embed( @@ -434,6 +441,7 @@ async def remove( new_total=new_total, is_unawarn=True, guild=guild, + max_awarn_weight=max_w, ), ephemeral=True, ) @@ -452,6 +460,7 @@ async def remove( new_total=new_total, is_unawarn=True, guild=guild, + max_awarn_weight=max_w, ), department_id=dept.id, ) @@ -470,6 +479,7 @@ async def remove( new_total=new_total, is_unawarn=True, guild=guild, + max_awarn_weight=max_w, ), ) await self._send_dm( @@ -484,6 +494,7 @@ async def remove( reason=reason, new_total=new_total, is_unawarn=True, + max_awarn_weight=max_w, ) # ---- public helper: perform a programmatic unawarn ----------------- @@ -529,6 +540,7 @@ async def perform_unawarn( # to the bot's own member object so the embed still has an author. embed_issuer = issuer if issuer is not None else (guild.me or target) warn_label = WARN_TYPES.get(warn_type, (warn_type, weight))[0] + max_w = department.max_awarn_weight await self._post_to_log( guild, @@ -546,6 +558,7 @@ async def perform_unawarn( is_unawarn=True, guild=guild, issuer_display_override=issuer_display_override, + max_awarn_weight=max_w, ), department_id=department.id, ) @@ -565,6 +578,7 @@ async def perform_unawarn( is_unawarn=True, guild=guild, issuer_display_override=issuer_display_override, + max_awarn_weight=max_w, ), ) await self._send_dm( @@ -579,6 +593,7 @@ async def perform_unawarn( reason=reason, new_total=new_total, is_unawarn=True, + max_awarn_weight=max_w, ) return new_total @@ -628,9 +643,14 @@ async def get( depts_with_totals = all_totals grand_total = sum(t for _, t in depts_with_totals if t > 0) + # For the overall color, use the worst ratio across departments. + worst_ratio = max( + (t / d.max_awarn_weight for d, t in depts_with_totals if d.max_awarn_weight > 0), + default=0.0, + ) embed = discord.Embed( title=f"{AWARN_ICON} Варны · {target.display_name}", - color=severity_color(grand_total) if depts_with_totals else BRAND_MUTED, + color=severity_color(worst_ratio, 1.0) if depts_with_totals else BRAND_MUTED, ) set_user_author(embed, target) if target.display_avatar: @@ -642,7 +662,10 @@ async def get( zero = [(d, t) for d, t in depts_with_totals if t <= 0] lines: list[str] = [] for d, total in non_zero: - lines.append(f"**{d.name}** - `{fmt_weight(total)}` {weight_bar(total)}") + lines.append( + f"**{d.name}** - `{fmt_weight(total)}` " + f"{weight_bar(total, ceiling=d.max_awarn_weight)}" + ) for d, _total in zero: lines.append(f"`✓` **{d.name}** - чисто") embed.add_field( @@ -766,12 +789,12 @@ async def set( ) embed.add_field( name=f"Шкала ({dept.name})", - value=f"`{fmt_weight(new_total)}` {weight_bar(new_total)}", + value=f"`{fmt_weight(new_total)}` {weight_bar(new_total, ceiling=dept.max_awarn_weight)}", inline=False, ) if user.display_avatar: embed.set_thumbnail(url=user.display_avatar.url) - embed.color = severity_color(new_total) + embed.color = severity_color(new_total, dept.max_awarn_weight) brand_footer(embed, guild=guild) await interaction.response.send_message(embed=embed, ephemeral=True) @@ -803,6 +826,7 @@ def _build_action_embed( is_unawarn: bool, guild: discord.Guild, issuer_display_override: str | None = None, + max_awarn_weight: float = 3.0, ) -> discord.Embed: """Used for both the issuer's ephemeral confirmation and the log channel. @@ -833,7 +857,7 @@ def _build_action_embed( ) embed.add_field( name=f"Шкала ({department_name})", - value=f"`{fmt_weight(new_total)}` {weight_bar(new_total)}", + value=f"`{fmt_weight(new_total)}` {weight_bar(new_total, ceiling=max_awarn_weight)}", inline=False, ) if reason: @@ -857,6 +881,7 @@ def _build_public_embed( is_unawarn: bool, guild: discord.Guild, issuer_display_override: str | None = None, + max_awarn_weight: float = 3.0, ) -> discord.Embed: """Used in the public warn channel - slightly less detail, focus on who/what.""" delta_sign = "−" if is_unawarn else "+" @@ -875,7 +900,7 @@ def _build_public_embed( embed.add_field(name="Департамент", value=department_name, inline=True) embed.add_field( name="Текущий вес", - value=f"`{fmt_weight(new_total)}` {weight_bar(new_total)}", + value=f"`{fmt_weight(new_total)}` {weight_bar(new_total, ceiling=max_awarn_weight)}", inline=False, ) if reason: diff --git a/src/spesobot/cogs/departments.py b/src/spesobot/cogs/departments.py index a7eba0c..b732c58 100644 --- a/src/spesobot/cogs/departments.py +++ b/src/spesobot/cogs/departments.py @@ -94,11 +94,15 @@ async def _ensure_admin(self, interaction: discord.Interaction) -> bool: name="create", description="Создать новый департамент (валюту) на сервере.", ) - @app_commands.describe(name="Название департамента (напр. «Модерация»)") + @app_commands.describe( + name="Название департамента (напр. «Модерация»)", + max_warn_weight="Максимальный вес варнов (шкала заполнится при этом значении). По умолчанию 3.", + ) async def create( self, interaction: discord.Interaction, name: app_commands.Range[str, 1, 80], + max_warn_weight: app_commands.Range[float, 0.25, 100.0] = 3.0, ) -> None: if not await require_configured_guild(self.db, interaction): return @@ -126,9 +130,11 @@ async def create( key=key, name=name.strip(), sort_order=existing_count, + max_awarn_weight=float(max_warn_weight), ) embed = success_embed( f"Департамент «{dept.name}» создан", + f"Максимальный вес варнов: **{max_warn_weight}**. " "Дальше - назначьте кураторов и (если нужно) донат-роли:", ) embed.add_field( @@ -277,6 +283,44 @@ async def log( brand_footer(embed, guild=guild) await interaction.response.send_message(embed=embed, ephemeral=True) + # ---- /department warn_limit ---------------------------------------- + + @department_group.command( + name="warn_limit", + description="Установить максимальный вес варнов для департамента.", + ) + @app_commands.describe( + name="Название департамента", + max_warn_weight="Максимальный вес варнов (шкала заполнится при этом значении).", + ) + @app_commands.autocomplete(name=department_autocomplete) + async def warn_limit( + self, + interaction: discord.Interaction, + name: str, + max_warn_weight: app_commands.Range[float, 0.25, 100.0], + ) -> None: + if not await require_configured_guild(self.db, interaction): + return + if not await self._ensure_admin(interaction): + return + guild = interaction.guild + assert guild is not None + + dept = await self._resolve(guild.id, name) + if dept is None: + await self._respond_unknown(interaction, name) + return + + await self.db.set_department_max_awarn_weight(dept.id, float(max_warn_weight)) + embed = success_embed( + f"Лимит варнов обновлён · {dept.name}", + f"Максимальный вес варнов для **{dept.name}** установлен: **{max_warn_weight}**.\n" + "Шкала и цвета в эмбедах будут рассчитываться относительно этого значения.", + ) + brand_footer(embed, guild=guild) + await interaction.response.send_message(embed=embed, ephemeral=True) + # ---- /department view ---------------------------------------------- @department_group.command( @@ -317,6 +361,7 @@ async def view(self, interaction: discord.Interaction) -> None: f"**Донат-роли:** {' '.join(f'<@&{r}>' for r in dons) if dons else '-'}", f"**Товаров в магазине:** {shop_count}", f"**Лог-канал:** {log_str}", + f"**Макс. вес варнов:** {d.max_awarn_weight:g}", ] embed.add_field( name=f"🏛 {d.name}", diff --git a/src/spesobot/cogs/points.py b/src/spesobot/cogs/points.py index 7d3dc09..9da1214 100644 --- a/src/spesobot/cogs/points.py +++ b/src/spesobot/cogs/points.py @@ -47,6 +47,46 @@ log = logging.getLogger(__name__) +# --------------------------------------------------------------------------- # +# Leaderboard helpers +# --------------------------------------------------------------------------- # + + +def _mini_bar(value: int, maximum: int, *, width: int = 8) -> str: + """Compact inline progress bar relative to the top score. + + e.g. ``▰▰▰▰▰▱▱▱`` for ~62% of the leader's score. + """ + if maximum <= 0: + ratio = 0.0 + else: + ratio = max(0.0, min(1.0, value / maximum)) + filled = round(ratio * width) + return "▰" * filled + "▱" * (width - filled) + + +def _join_truncated(items: list[str], *, sep: str, max_len: int) -> str: + """Join ``items`` with ``sep``, stopping before ``max_len`` chars. + + If the full join would exceed the limit, appends ``…`` so Discord + never receives an oversized field/description value. + """ + parts: list[str] = [] + total = 0 + ellipsis = "…" + for item in items: + needed = len(item) + (len(sep) if parts else 0) + if total + needed + (len(sep) if parts else 0) + len(ellipsis) > max_len: + # Only append ellipsis if it actually fits (including its own separator). + ellipsis_cost = (len(sep) if parts else 0) + len(ellipsis) + if total + ellipsis_cost <= max_len: + parts.append(ellipsis) + break + parts.append(item) + total += needed + return sep.join(parts) if parts else "" + + # --------------------------------------------------------------------------- # # /history_export builder # --------------------------------------------------------------------------- # @@ -835,72 +875,210 @@ async def _leaderboard_department_autocomplete( break return choices - @app_commands.command(name="leaderboard", description="Топ по баллам конкретного департамента.") + @app_commands.command( + name="leaderboard", + description="Топ участников по баллам. Без департамента — общий рейтинг по всем.", + ) @app_commands.describe( - department="Департамент, по которому строить топ", - limit="Сколько мест показать (1–25)", + department="Конкретный департамент (необязательно — без него показывается общий топ)", + limit="Сколько мест показать (1–25, по умолчанию 10)", ) @app_commands.autocomplete(department=_leaderboard_department_autocomplete) async def leaderboard( self, interaction: discord.Interaction, - department: str, + department: str | None = None, limit: app_commands.Range[int, 1, 25] = 10, ) -> None: if not await require_configured_guild(self.db, interaction): return - if interaction.guild is None or not isinstance(interaction.user, discord.Member): + guild = interaction.guild + if guild is None or not isinstance(interaction.user, discord.Member): await interaction.response.send_message( embed=error_embed("Только на сервере", "Команда работает только в гильдии."), ephemeral=True, ) return - dept = await self._resolve_department(interaction, department) - if dept is None: - await self._respond_unknown_dept(interaction, department) + + caller = interaction.user + + # ---- single-department mode ---- + if department is not None: + dept = await self._resolve_department(interaction, department) + if dept is None: + await self._respond_unknown_dept(interaction, department) + return + if not await self._can_view_leaderboard(caller, dept.id): + await interaction.response.send_message( + embed=error_embed( + "Нет доступа", + f"Топ департамента **{dept.name}** виден только участникам " + "этого департамента (или администрации).", + ), + ephemeral=True, + ) + return + rows = await self.db.dept_leaderboard(dept.id, limit=limit) + if not rows: + await interaction.response.send_message( + embed=info_embed( + f"Доска «{dept.name}» пуста", + "В этом департаменте пока никто не получал баллов.", + ), + ephemeral=True, + ) + return + + caller_rank = await self.db.dept_user_rank(dept.id, caller.id) + caller_pts = await self.db.get_dept_balance(caller.id, dept.id) + top_pts = rows[0][1] + + lines: list[str] = [] + for i, (user_id, points) in enumerate(rows, start=1): + is_caller = user_id == caller.id + bar = _mini_bar(points, top_pts) + tag = " **← вы**" if is_caller else "" + lines.append( + f"{medal(i)} <@{user_id}>\n" + f" {bar} **{fmt_points(points, with_coin=False)}** {COIN}{tag}" + ) + + embed = discord.Embed( + title=f"🏆 {dept.name}", + description="\n".join(lines), + color=BRAND_GOLD, + ) + # Thumbnail — top member's avatar. + top_member = guild.get_member(rows[0][0]) + if top_member: + embed.set_thumbnail(url=top_member.display_avatar.url) + # Server icon as author. + if guild.icon: + embed.set_author(name=guild.name, icon_url=guild.icon.url) + # Caller's position if not in the visible list. + if caller_pts > 0: + caller_in_list = any(uid == caller.id for uid, _ in rows) + if not caller_in_list and caller_rank is not None: + embed.add_field( + name="Ваше место", + value=( + f"{medal(caller_rank)} <@{caller.id}> — " + f"**{fmt_points(caller_pts, with_coin=False)}** {COIN}" + ), + inline=False, + ) + embed.set_footer( + text=f"Spesobot · {guild.name} · Топ {limit}", + icon_url=guild.icon.url if guild.icon else None, + ) + embed.timestamp = datetime.now(tz=UTC) + await interaction.response.send_message(embed=embed) return - if not await self._can_view_leaderboard(interaction.user, dept.id): + # ---- global mode (all departments) ---- + all_depts = await self.db.list_departments(guild.id) + if not all_depts: await interaction.response.send_message( - embed=error_embed( - "Нет доступа", - f"Топ департамента **{dept.name}** виден только участникам " - "этого департамента (или администрации).", - ), + embed=info_embed("Нет департаментов", "На сервере ещё нет ни одного департамента."), ephemeral=True, ) return - rows = await self.db.dept_leaderboard(dept.id, limit=limit) - if not rows: + # Filter to departments the caller can see. + is_privileged = await is_admin(self.db, caller) or await is_auditor(self.db, caller) + if not is_privileged: + visible_dept_ids: set[int] = set() + for d in all_depts: + if await is_curator_of_department(self.db, caller, d.id): + visible_dept_ids.add(d.id) + elif await self.db.get_dept_balance(caller.id, d.id) > 0: + visible_dept_ids.add(d.id) + if not visible_dept_ids: + await interaction.response.send_message( + embed=info_embed( + "Нет данных", + "У вас нет баллов ни в одном департаменте.", + ), + ephemeral=True, + ) + return + else: + visible_dept_ids = {d.id for d in all_depts} + + dept_map = {d.id: d for d in all_depts} + global_rows = await self.db.guild_leaderboard( + guild.id, + limit=limit, + dept_ids=None if is_privileged else visible_dept_ids, + ) + + if not global_rows: await interaction.response.send_message( - embed=info_embed( - f"Доска «{dept.name}» пуста", - "В этом департаменте пока никто не получал баллов.", - ), + embed=info_embed("Доска пуста", "Пока никто не получал баллов."), ephemeral=True, ) return - caller_id = interaction.user.id + top_total = global_rows[0][1] lines: list[str] = [] - for i, (user_id, points) in enumerate(rows, start=1): - mark = " ◀" if user_id == caller_id else "" + for i, (user_id, total, breakdown) in enumerate(global_rows, start=1): + is_caller = user_id == caller.id + bar = _mini_bar(total, top_total) + tag = " **← вы**" if is_caller else "" + # Per-dept breakdown (only depts with points, sorted by points desc). + dept_parts = sorted(breakdown.items(), key=lambda x: -x[1]) + dept_str = " ".join( + f"{dept_map[did].name} **{fmt_points(pts, with_coin=False)}** {COIN}" + for did, pts in dept_parts + if did in dept_map + ) lines.append( - f"{medal(i)} <@{user_id}> - **{fmt_points(points, with_coin=False)}** {COIN}{mark}" + f"{medal(i)} <@{user_id}>{tag}\n" + f" {bar} **{fmt_points(total, with_coin=False)}** {COIN} всего\n" + f" ┗ {dept_str}" ) + # Build description with a hard cap at Discord's 4096-char limit. + description = _join_truncated(lines, sep="\n\n", max_len=4096) + + dept_names = " · ".join(d.name for d in all_depts if d.id in visible_dept_ids) embed = discord.Embed( - title=f"🏆 Топ департамента «{dept.name}»", - description="\n".join(lines), + title="🏆 Общий рейтинг", + description=description, color=BRAND_GOLD, ) - if interaction.guild and rows: - top_user_id = rows[0][0] - top_member = interaction.guild.get_member(top_user_id) - if top_member: - embed.set_thumbnail(url=top_member.display_avatar.url) - brand_footer(embed, guild=interaction.guild) + if guild.icon: + embed.set_author(name=guild.name, icon_url=guild.icon.url) + top_member = guild.get_member(global_rows[0][0]) + if top_member: + embed.set_thumbnail(url=top_member.display_avatar.url) + embed.add_field( + name="Департаменты", + value=dept_names[:1024] or "—", + inline=False, + ) + # Caller's position if not in the visible list. + caller_in_list = any(uid == caller.id for uid, _, _ in global_rows) + if not caller_in_list: + caller_rank, caller_total = await self.db.guild_user_total_rank( + guild.id, + caller.id, + dept_ids=None if is_privileged else visible_dept_ids, + ) + if caller_rank is not None: + embed.add_field( + name="Ваше место", + value=( + f"{medal(caller_rank)} <@{caller.id}> — " + f"**{fmt_points(caller_total, with_coin=False)}** {COIN}" + ), + inline=False, + ) + embed.set_footer( + text=f"Spesobot · {guild.name} · Топ {limit}", + icon_url=guild.icon.url if guild.icon else None, + ) + embed.timestamp = datetime.now(tz=UTC) await interaction.response.send_message(embed=embed) # ---- helpers ------------------------------------------------------- diff --git a/src/spesobot/db.py b/src/spesobot/db.py index cc2be88..3dc95f0 100644 --- a/src/spesobot/db.py +++ b/src/spesobot/db.py @@ -27,7 +27,7 @@ log = logging.getLogger(__name__) -CURRENT_SCHEMA_VERSION = 8 +CURRENT_SCHEMA_VERSION = 10 # -- v1 schema (kept for legacy fresh installs that immediately upgrade) ------- @@ -225,6 +225,7 @@ class Department: sort_order: int created_at: datetime log_channel_id: int | None = None + max_awarn_weight: float = 3.0 @dataclass(slots=True) @@ -235,6 +236,7 @@ class ShopItem: price: int duration_days: int description: str + icon_emoji: str = "" price_growth: float = 1.0 def effective_price(self, prior_count: int) -> int: @@ -392,6 +394,16 @@ async def run(self) -> None: await self._set_version(8) log.info("DB migrated: v7 -> v8 (shop_items.price_growth)") version = 8 + if version < 9: + await self._migrate_to_v9() + await self._set_version(9) + log.info("DB migrated: v8 -> v9 (shop_items.icon_emoji)") + version = 9 + if version < 10: + await self._migrate_to_v10() + await self._set_version(10) + log.info("DB migrated: v9 -> v10 (departments.max_awarn_weight)") + version = 10 async def _get_version(self) -> int: try: @@ -585,6 +597,21 @@ async def _migrate_to_v8(self) -> None: await self.conn.commit() log.info("DB migrated: v7 -> v8 (shop_items.price_growth added)") + async def _migrate_to_v9(self) -> None: + if not await self._column_exists("shop_items", "icon_emoji"): + await self.conn.execute( + "ALTER TABLE shop_items ADD COLUMN icon_emoji TEXT NOT NULL DEFAULT ''" + ) + await self.conn.commit() + + async def _migrate_to_v10(self) -> None: + # Per-department maximum awarn weight. When a user's total reaches + # this value the bar is full. Default 3.0 (was previously hardcoded + # as ceiling=5.0 in weight_bar, but the practical max was 4 warns). + await self._add_column_if_missing( + "departments", "max_awarn_weight", "REAL NOT NULL DEFAULT 3.0" + ) + async def _column_exists(self, table: str, column: str) -> bool: async with self.conn.execute(f"PRAGMA table_info({table})") as cur: cols = await cur.fetchall() @@ -744,11 +771,12 @@ async def create_department( key: str, name: str, sort_order: int = 0, + max_awarn_weight: float = 3.0, ) -> Department: async with self.conn.execute( - "INSERT INTO departments(guild_id, key, name, sort_order) " - "VALUES (?, ?, ?, ?) RETURNING id, created_at", - (guild_id, key, name, sort_order), + "INSERT INTO departments(guild_id, key, name, sort_order, max_awarn_weight) " + "VALUES (?, ?, ?, ?, ?) RETURNING id, created_at", + (guild_id, key, name, sort_order, float(max_awarn_weight)), ) as cur: row = await cur.fetchone() await self.conn.commit() @@ -760,6 +788,7 @@ async def create_department( name=name, sort_order=sort_order, created_at=_parse_dt(str(row[1])), + max_awarn_weight=float(max_awarn_weight), ) async def remove_department(self, department_id: int) -> bool: @@ -774,7 +803,8 @@ async def remove_department(self, department_id: int) -> bool: async def get_department(self, department_id: int) -> Department | None: async with self.conn.execute( - "SELECT id, guild_id, key, name, sort_order, created_at, log_channel_id " + "SELECT id, guild_id, key, name, sort_order, created_at, log_channel_id, " + " max_awarn_weight " "FROM departments WHERE id = ?", (department_id,), ) as cur: @@ -783,7 +813,8 @@ async def get_department(self, department_id: int) -> Department | None: async def get_department_by_key(self, guild_id: int, key: str) -> Department | None: async with self.conn.execute( - "SELECT id, guild_id, key, name, sort_order, created_at, log_channel_id " + "SELECT id, guild_id, key, name, sort_order, created_at, log_channel_id, " + " max_awarn_weight " "FROM departments WHERE guild_id = ? AND key = ?", (guild_id, key), ) as cur: @@ -792,7 +823,8 @@ async def get_department_by_key(self, guild_id: int, key: str) -> Department | N async def list_departments(self, guild_id: int) -> list[Department]: async with self.conn.execute( - "SELECT id, guild_id, key, name, sort_order, created_at, log_channel_id " + "SELECT id, guild_id, key, name, sort_order, created_at, log_channel_id, " + " max_awarn_weight " "FROM departments WHERE guild_id = ? ORDER BY sort_order ASC, id ASC", (guild_id,), ) as cur: @@ -807,6 +839,16 @@ async def set_department_log_channel(self, department_id: int, channel_id: int | ) await self.conn.commit() + async def set_department_max_awarn_weight( + self, department_id: int, max_awarn_weight: float + ) -> None: + """Set the maximum awarn weight for a department (used as gauge ceiling).""" + await self.conn.execute( + "UPDATE departments SET max_awarn_weight = ? WHERE id = ?", + (float(max_awarn_weight), department_id), + ) + await self.conn.commit() + async def resolve_log_channel_id(self, guild_id: int, department_id: int | None) -> int | None: """Resolve where to post a log entry. @@ -877,7 +919,7 @@ async def list_user_curator_departments( return [] placeholders = ",".join("?" for _ in user_role_ids) async with self.conn.execute( - f"SELECT DISTINCT d.id, d.guild_id, d.key, d.name, d.sort_order, d.created_at, d.log_channel_id " + f"SELECT DISTINCT d.id, d.guild_id, d.key, d.name, d.sort_order, d.created_at, d.log_channel_id, d.max_awarn_weight " f"FROM departments d " f"JOIN department_curator_roles dcr ON dcr.department_id = d.id " f"WHERE d.guild_id = ? AND dcr.role_id IN ({placeholders}) " @@ -891,7 +933,7 @@ async def find_department_for_donator_role( self, guild_id: int, role_id: int ) -> Department | None: async with self.conn.execute( - "SELECT d.id, d.guild_id, d.key, d.name, d.sort_order, d.created_at, d.log_channel_id " + "SELECT d.id, d.guild_id, d.key, d.name, d.sort_order, d.created_at, d.log_channel_id, d.max_awarn_weight " "FROM departments d " "JOIN department_donator_roles ddr ON ddr.department_id = d.id " "WHERE d.guild_id = ? AND ddr.role_id = ? " @@ -917,6 +959,7 @@ async def get_user_dept_balances( """All (department, balance) pairs for a user in a guild - including zeros.""" async with self.conn.execute( "SELECT d.id, d.guild_id, d.key, d.name, d.sort_order, d.created_at, d.log_channel_id, " + "d.max_awarn_weight, " "COALESCE(db.points, 0) " "FROM departments d " "LEFT JOIN department_balances db " @@ -926,7 +969,7 @@ async def get_user_dept_balances( (user_id, guild_id), ) as cur: rows = await cur.fetchall() - return [(_department_from_row(r), int(r[7])) for r in rows] + return [(_department_from_row(r), int(r[8])) for r in rows] async def get_total_balance(self, user_id: int, guild_id: int) -> int: async with self.conn.execute( @@ -1044,6 +1087,141 @@ async def dept_leaderboard(self, department_id: int, limit: int = 10) -> list[tu rows = await cur.fetchall() return [(int(r[0]), int(r[1])) for r in rows] + async def guild_leaderboard( + self, + guild_id: int, + limit: int = 10, + *, + dept_ids: set[int] | None = None, + ) -> list[tuple[int, int, dict[int, int]]]: + """Return top ``limit`` users by total points. + + If ``dept_ids`` is given, only those departments contribute to the + total and the LIMIT is applied *after* that filter — so the result + is always the true top-``limit`` within the visible scope. + + Returns ``[(user_id, total_points, {dept_id: points})]``. + """ + if dept_ids is not None: + placeholders = ",".join("?" for _ in dept_ids) + sql = ( + "SELECT db.user_id, SUM(db.points) AS total " + "FROM department_balances db " + f"WHERE db.department_id IN ({placeholders}) AND db.points > 0 " + "GROUP BY db.user_id " + "ORDER BY total DESC, db.user_id ASC " + "LIMIT ?" + ) + params: tuple = (*dept_ids, limit) + else: + sql = ( + "SELECT db.user_id, SUM(db.points) AS total " + "FROM department_balances db " + "JOIN departments d ON d.id = db.department_id " + "WHERE d.guild_id = ? AND db.points > 0 " + "GROUP BY db.user_id " + "ORDER BY total DESC, db.user_id ASC " + "LIMIT ?" + ) + params = (guild_id, limit) + async with self.conn.execute(sql, params) as cur: + rows = await cur.fetchall() + if not rows: + return [] + top_user_ids = [int(r[0]) for r in rows] + totals = {int(r[0]): int(r[1]) for r in rows} + # Fetch per-dept breakdown for the top users, restricted to visible depts. + uid_placeholders = ",".join("?" for _ in top_user_ids) + if dept_ids is not None: + dept_placeholders = ",".join("?" for _ in dept_ids) + detail_sql = ( + f"SELECT db.user_id, db.department_id, db.points " + f"FROM department_balances db " + f"WHERE db.user_id IN ({uid_placeholders}) " + f"AND db.department_id IN ({dept_placeholders}) " + f"AND db.points > 0" + ) + detail_params: tuple = (*top_user_ids, *dept_ids) + else: + detail_sql = ( + f"SELECT db.user_id, db.department_id, db.points " + f"FROM department_balances db " + f"JOIN departments d ON d.id = db.department_id " + f"WHERE d.guild_id = ? AND db.user_id IN ({uid_placeholders}) AND db.points > 0" + ) + detail_params = (guild_id, *top_user_ids) + async with self.conn.execute(detail_sql, detail_params) as cur: + detail_rows = await cur.fetchall() + breakdown: dict[int, dict[int, int]] = {uid: {} for uid in top_user_ids} + for r in detail_rows: + uid, dept_id, pts = int(r[0]), int(r[1]), int(r[2]) + if uid in breakdown: + breakdown[uid][dept_id] = pts + return sorted( + [(uid, totals[uid], breakdown[uid]) for uid in top_user_ids], + key=lambda x: -x[1], + ) + + async def guild_user_total_rank( + self, + guild_id: int, + user_id: int, + *, + dept_ids: set[int] | None = None, + ) -> tuple[int | None, int]: + """Return ``(rank, total)`` for ``user_id`` scoped to ``dept_ids``. + + If ``dept_ids`` is None, all departments in the guild are used. + ``rank`` is 1-based; ``None`` if the user has no points in scope. + """ + if dept_ids is not None: + placeholders = ",".join("?" for _ in dept_ids) + total_sql = ( + f"SELECT SUM(db.points) FROM department_balances db " + f"WHERE db.department_id IN ({placeholders}) AND db.user_id = ?" + ) + total_params: tuple = (*dept_ids, user_id) + else: + total_sql = ( + "SELECT SUM(db.points) FROM department_balances db " + "JOIN departments d ON d.id = db.department_id " + "WHERE d.guild_id = ? AND db.user_id = ?" + ) + total_params = (guild_id, user_id) + async with self.conn.execute(total_sql, total_params) as cur: + row = await cur.fetchone() + total = int(row[0]) if row and row[0] else 0 + if total == 0: + return None, 0 + if dept_ids is not None: + dept_placeholders = ",".join("?" for _ in dept_ids) + rank_sql = ( + "SELECT COUNT(*) FROM (" + " SELECT db.user_id, SUM(db.points) AS t " + " FROM department_balances db " + f" WHERE db.department_id IN ({dept_placeholders}) " + " GROUP BY db.user_id " + " HAVING t > ?" + ")" + ) + rank_params: tuple = (*dept_ids, total) + else: + rank_sql = ( + "SELECT COUNT(*) FROM (" + " SELECT db.user_id, SUM(db.points) AS t " + " FROM department_balances db " + " JOIN departments d ON d.id = db.department_id " + " WHERE d.guild_id = ? " + " GROUP BY db.user_id " + " HAVING t > ?" + ")" + ) + rank_params = (guild_id, total) + async with self.conn.execute(rank_sql, rank_params) as cur: + row = await cur.fetchone() + rank = (int(row[0]) if row else 0) + 1 + return rank, total + async def dept_user_rank(self, department_id: int, user_id: int) -> int | None: async with self.conn.execute( "SELECT (SELECT COUNT(*) FROM department_balances db2 " @@ -1066,20 +1244,22 @@ async def upsert_shop_item( price: int, duration_days: int, description: str = "", + icon_emoji: str = "", ) -> None: # ``price_growth`` is intentionally *not* updated here - it has its # own admin command (``/shop_admin role growth``) so re-running ``/shop_admin role set`` # for an existing item won't reset the inflation multiplier. await self.conn.execute( "INSERT INTO shop_items(guild_id, department_id, role_id, price, " - " duration_days, description, price_growth, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, 1.0, datetime('now')) " + " duration_days, description, icon_emoji, price_growth, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, 1.0, datetime('now')) " "ON CONFLICT(guild_id, department_id, role_id) DO UPDATE SET " " price = excluded.price, " " duration_days = excluded.duration_days, " " description = excluded.description, " + " icon_emoji = excluded.icon_emoji, " " updated_at = datetime('now')", - (guild_id, department_id, role_id, price, duration_days, description), + (guild_id, department_id, role_id, price, duration_days, description, icon_emoji), ) await self.conn.commit() @@ -1156,7 +1336,7 @@ async def list_shop_items( if department_id is None: sql = ( "SELECT guild_id, department_id, role_id, price, duration_days, " - " description, price_growth " + " description, icon_emoji, price_growth " "FROM shop_items WHERE guild_id = ? " "ORDER BY department_id ASC, price ASC" ) @@ -1164,7 +1344,7 @@ async def list_shop_items( else: sql = ( "SELECT guild_id, department_id, role_id, price, duration_days, " - " description, price_growth " + " description, icon_emoji, price_growth " "FROM shop_items WHERE guild_id = ? AND department_id = ? " "ORDER BY price ASC" ) @@ -1178,7 +1358,7 @@ async def get_shop_item( ) -> ShopItem | None: async with self.conn.execute( "SELECT guild_id, department_id, role_id, price, duration_days, " - " description, price_growth " + " description, icon_emoji, price_growth " "FROM shop_items " "WHERE guild_id = ? AND department_id = ? AND role_id = ?", (guild_id, department_id, role_id), @@ -1639,6 +1819,9 @@ def _department_from_row(r: aiosqlite.Row | tuple) -> Department: log_channel_id: int | None = None if len(r) > 6 and r[6] is not None: log_channel_id = int(r[6]) + max_awarn_weight: float = 3.0 + if len(r) > 7 and r[7] is not None: + max_awarn_weight = float(r[7]) return Department( id=int(r[0]), guild_id=int(r[1]), @@ -1647,10 +1830,16 @@ def _department_from_row(r: aiosqlite.Row | tuple) -> Department: sort_order=int(r[4]), created_at=_parse_dt(str(r[5])), log_channel_id=log_channel_id, + max_awarn_weight=max_awarn_weight, ) def _shop_item_from_row(r: aiosqlite.Row | tuple) -> ShopItem: + icon_emoji = "" + price_growth_idx = 6 + if len(r) > 7: + icon_emoji = str(r[6]) + price_growth_idx = 7 return ShopItem( guild_id=int(r[0]), department_id=int(r[1]) if r[1] is not None else 0, @@ -1658,7 +1847,8 @@ def _shop_item_from_row(r: aiosqlite.Row | tuple) -> ShopItem: price=int(r[3]), duration_days=int(r[4]), description=str(r[5]), - price_growth=float(r[6]) if r[6] is not None else 1.0, + icon_emoji=icon_emoji, + price_growth=float(r[price_growth_idx]) if r[price_growth_idx] is not None else 1.0, ) diff --git a/src/spesobot/format.py b/src/spesobot/format.py index 2057216..5fe624b 100644 --- a/src/spesobot/format.py +++ b/src/spesobot/format.py @@ -191,16 +191,19 @@ def medal(rank: int) -> str: WEIGHT_BAR_EMPTY = "░" -def severity_color(weight: float) -> discord.Color: - """Tint based on accumulated awarn weight. +def severity_color(weight: float, max_weight: float = 3.0) -> discord.Color: + """Tint based on accumulated awarn weight relative to the department max. - < 0.5 - green (clean), 0.5-1.99 - gold, 2.0-3.99 - orange, ≥4.0 - red. + < 33% max - green (clean), 33-66% - gold, 66-99% - orange, ≥ max - red. """ - if weight < 0.5: + if max_weight <= 0: + return BRAND_ERROR + ratio = weight / max_weight + if ratio < 0.33: return BRAND_SUCCESS - if weight < 2.0: + if ratio < 0.66: return BRAND_GOLD - if weight < 4.0: + if ratio < 1.0: return BRAND_DEBIT return BRAND_ERROR