Files
tipibot/core/economy/leaderboards.py
Rene Arumetsa eb7346b851 fix(economy): handle db_error in commands, dedupe cooldowns, single leaderboard scan
Three robustness/perf fixes from the review:

1. db_error UX: economy core returns {"ok": False, "reason": "db_error"} on a
   PocketBase outage, but no handler expected it - deferred commands hung on
   "thinking..." and others showed a misleading "you're broke". Added a shared
   reply_db_error helper (commands/_replies.py) + S.ERR["db_error"], and wired a
   db_error branch into every handler that can receive it (daily/work/beg/crime/
   rob/give/buy/roulette/slots/blackjack/heist/fish/prestige/vanity/consumables/
   request-funding). Also fixed a latent KeyError in vs-bot RPS that read
   res["balance"] without checking res["ok"].

2. Deduped the item->cooldown mapping that was copied in do_daily/do_work/do_beg,
   do_fish_start, _maybe_remind and _restore_reminders. Single source of truth:
   store.ITEM_COOLDOWNS + effective_cooldown(cmd, items).

3. /leaderboard did six full-collection scans (one per tab). Added
   get_all_leaderboards() which scans once and builds all six views in memory.

Tests: effective_cooldown cases, and get_all_leaderboards matches the individual
queries + scans the collection exactly once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:26:07 +03:00

112 lines
4.5 KiB
Python

"""Leaderboard queries over the full collection."""
from __future__ import annotations
from .. import pb_client
from .levels import get_level
async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return top_n (user_id_str, balance) pairs sorted descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]:
"""Return top_n (user_id_str, exp, level) sorted by EXP descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
entries = [(uid, exp, get_level(exp)) for uid, exp in result]
return entries if top_n is None else entries[:top_n]
# ---------------------------------------------------------------------------
# Extended leaderboards
# ---------------------------------------------------------------------------
async def get_leaderboard_season_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]:
"""Return (user_id, season_total_exp, prestige_level) sorted by season EXP."""
records = await pb_client.list_all_records()
result = sorted(
(
(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0))
for r in records if r.get("user_id")
),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_prestige(top_n: int | None = 10) -> list[tuple[str, int, int]]:
"""Return (user_id, prestige_level, prestige_points) sorted by prestige_level then PP."""
records = await pb_client.list_all_records()
result = sorted(
(
(r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0))
for r in records if r.get("user_id")
),
key=lambda x: (x[1], x[2]),
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_wagered(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return (user_id, total_wagered) sorted descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("total_wagered", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_fish(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return (user_id, total_fish_caught) sorted descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("total_fish_caught", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_all_leaderboards() -> dict[str, list[tuple]]:
"""Build every leaderboard view from a SINGLE collection scan.
/leaderboard shows six tabs; calling each get_leaderboard_* separately would
read the whole collection six times. This reads once and sorts in memory,
returning the same tuple shapes the individual functions produce (unbounded -
the command paginates)."""
records = await pb_client.list_all_records()
users = [r for r in records if r.get("user_id")]
def desc(keyfn) -> list[dict]:
return sorted(users, key=keyfn, reverse=True)
return {
"coins": [(r["user_id"], r.get("balance", 0))
for r in desc(lambda r: r.get("balance", 0))],
"exp": [(r["user_id"], r.get("exp", 0), get_level(r.get("exp", 0)))
for r in desc(lambda r: r.get("exp", 0))],
"season": [(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0))
for r in desc(lambda r: r.get("season_total_exp", 0))],
"prestige": [(r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0))
for r in desc(lambda r: (r.get("prestige_level", 0), r.get("prestige_points", 0)))],
"wagered": [(r["user_id"], r.get("total_wagered", 0))
for r in desc(lambda r: r.get("total_wagered", 0))],
"fish": [(r["user_id"], r.get("total_fish_caught", 0))
for r in desc(lambda r: r.get("total_fish_caught", 0))],
}