From eb7346b85182d6dbeb55fd18866a1c57f03e5637 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:26:07 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- bot.py | 24 ++----------- commands/_replies.py | 25 ++++++++++++++ commands/economy_extra_commands.py | 29 +++++++++++----- commands/economy_fish_commands.py | 5 +++ commands/economy_games_commands.py | 26 +++++++++++++- commands/economy_income_commands.py | 17 ++++++++++ commands/economy_prestige_commands.py | 11 ++++++ commands/economy_support_commands.py | 11 ++++++ core/economy/fishing.py | 7 ++-- core/economy/income.py | 13 +++---- core/economy/leaderboards.py | 29 ++++++++++++++++ core/economy/store.py | 20 +++++++++++ strings/common.py | 1 + tests/test_economy_pure.py | 23 +++++++++++++ tests/test_leaderboards.py | 49 +++++++++++++++++++++++++++ 15 files changed, 249 insertions(+), 41 deletions(-) create mode 100644 commands/_replies.py create mode 100644 tests/test_leaderboards.py diff --git a/bot.py b/bot.py index 932eee8..d066e92 100644 --- a/bot.py +++ b/bot.py @@ -772,17 +772,7 @@ async def _restore_reminders() -> None: last_str = user.get(last_key) if not last_str: continue - items = user.get("items", []) - if cmd == "work" and "monitor" in items: - cooldown = datetime.timedelta(minutes=40) - elif cmd == "beg" and "hiirematt" in items: - cooldown = datetime.timedelta(minutes=3) - elif cmd == "daily" and "korvaklapid" in items: - cooldown = datetime.timedelta(hours=18) - elif cmd == "fish" and "ussipurk" in items: - cooldown = datetime.timedelta(seconds=90) - else: - cooldown = economy.COOLDOWNS.get(cmd) + cooldown = economy.effective_cooldown(cmd, user.get("items", [])) if not cooldown: continue last_dt = datetime.datetime.fromisoformat(last_str) @@ -801,17 +791,7 @@ async def _maybe_remind(user_id: int, cmd: str) -> None: user_data = await economy.get_user(user_id) if cmd not in user_data.get("reminders", []): return - items = set(user_data.get("items", [])) - if cmd == "work" and "monitor" in items: - delay = datetime.timedelta(minutes=40) - elif cmd == "beg" and "hiirematt" in items: - delay = datetime.timedelta(minutes=3) - elif cmd == "daily" and "korvaklapid" in items: - delay = datetime.timedelta(hours=18) - elif cmd == "fish" and "ussipurk" in items: - delay = datetime.timedelta(seconds=90) - else: - delay = economy.COOLDOWNS.get(cmd, datetime.timedelta(hours=1)) + delay = economy.effective_cooldown(cmd, user_data.get("items", [])) or datetime.timedelta(hours=1) _schedule_reminder(user_id, cmd, delay) diff --git a/commands/_replies.py b/commands/_replies.py new file mode 100644 index 0000000..e37e542 --- /dev/null +++ b/commands/_replies.py @@ -0,0 +1,25 @@ +"""Shared reply helpers for command handlers.""" + +from __future__ import annotations + +import discord + +import strings as S + + +async def reply_db_error(interaction: discord.Interaction) -> None: + """Tell the user the database is unavailable. + + Economy core functions return {"ok": False, "reason": "db_error"} on a + PocketBase outage. Without this, handlers either fall through silently (a + deferred interaction hangs on "thinking...") or show a misleading "you're + broke" message. Works whether or not the interaction was already deferred. + """ + msg = S.ERR["db_error"] + try: + if interaction.response.is_done(): + await interaction.followup.send(msg, ephemeral=True) + else: + await interaction.response.send_message(msg, ephemeral=True) + except discord.HTTPException: + pass diff --git a/commands/economy_extra_commands.py b/commands/economy_extra_commands.py index 6b8356f..c846ef4 100644 --- a/commands/economy_extra_commands.py +++ b/commands/economy_extra_commands.py @@ -13,6 +13,8 @@ from core import economy from core.emoji import EMOJI as E import strings as S +from ._replies import reply_db_error + def register_economy_extra_commands( tree: app_commands.CommandTree, @@ -120,6 +122,9 @@ def register_economy_extra_commands( return res = await economy.do_heist_check(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": @@ -270,6 +275,9 @@ def register_economy_extra_commands( return res = await economy.do_heist_check(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": @@ -518,6 +526,9 @@ def register_economy_extra_commands( res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": @@ -751,14 +762,13 @@ def register_economy_extra_commands( @tree.command(name="leaderboard", description=S.CMD["leaderboard"]) async def cmd_leaderboard(interaction: discord.Interaction): await interaction.response.defer() - coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather( - economy.get_leaderboard(top_n=None), - economy.get_leaderboard_exp(top_n=None), - economy.get_leaderboard_season_exp(top_n=None), - economy.get_leaderboard_prestige(top_n=None), - economy.get_leaderboard_wagered(top_n=None), - economy.get_leaderboard_fish(top_n=None), - ) + lbs = await economy.get_all_leaderboards() # single collection scan for all six tabs + coins_raw = lbs["coins"] + exp_raw = lbs["exp"] + season_raw = lbs["season"] + prestige_raw = lbs["prestige"] + wagered_raw = lbs["wagered"] + fish_raw = lbs["fish"] house_entry = None regular = [] @@ -865,6 +875,9 @@ def register_economy_extra_commands( async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]): res = await economy.do_buy(interaction.user.id, ese.value) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "owned": diff --git a/commands/economy_fish_commands.py b/commands/economy_fish_commands.py index 9c9c81d..dfd6bda 100644 --- a/commands/economy_fish_commands.py +++ b/commands/economy_fish_commands.py @@ -10,6 +10,8 @@ from discord import app_commands from core import economy import strings as S +from ._replies import reply_db_error + def register_economy_fish_commands( tree: app_commands.CommandTree, @@ -218,6 +220,9 @@ def register_economy_fish_commands( res = await economy.do_fish_start(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": diff --git a/commands/economy_games_commands.py b/commands/economy_games_commands.py index 3f0dbfb..7b80251 100644 --- a/commands/economy_games_commands.py +++ b/commands/economy_games_commands.py @@ -12,6 +12,8 @@ from core import economy from core.emoji import EMOJI as E import strings as S +from ._replies import reply_db_error + def register_economy_games_commands( tree: app_commands.CommandTree, @@ -116,6 +118,9 @@ def register_economy_games_commands( res = await economy.do_roulette(interaction.user.id, panus_int, värv.value) if not res["ok"]: active_games.discard(interaction.user.id) + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": @@ -202,7 +207,14 @@ def register_economy_games_commands( bet_line = "" if self.bet > 0: res = await economy.do_game_bet(interaction.user.id, self.bet, outcome) - if outcome == "win": + if not res.get("ok"): + if res.get("reason") == "db_error": + await reply_db_error(interaction) + return + # Player was jailed/went broke since the duel started - show the + # result without a bet line rather than crashing on res["balance"]. + bet_line = "" + elif outcome == "win": bet_line = S.RPS_UI["bet_win"].format(amount=coin(self.bet), balance=coin(res["balance"])) elif outcome == "lose": bet_line = S.RPS_UI["bet_lose"].format(amount=coin(self.bet), balance=coin(res["balance"])) @@ -652,6 +664,9 @@ def register_economy_games_commands( res = await economy.do_slots(interaction.user.id, panus_int) if not res["ok"]: active_games.discard(interaction.user.id) + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) return @@ -1017,6 +1032,9 @@ def register_economy_games_commands( try: res = await economy.do_blackjack_bet(self.user_id, self.bet) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return await interaction.response.send_message( S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True ) @@ -1042,6 +1060,9 @@ def register_economy_games_commands( try: res = await economy.do_blackjack_bet(self.user_id, self.bet) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return await interaction.response.send_message( S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True ) @@ -1101,6 +1122,9 @@ def register_economy_games_commands( res = await economy.do_blackjack_bet(interaction.user.id, bet) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": diff --git a/commands/economy_income_commands.py b/commands/economy_income_commands.py index c6b1088..be69575 100644 --- a/commands/economy_income_commands.py +++ b/commands/economy_income_commands.py @@ -10,6 +10,8 @@ from discord import app_commands from core import economy import strings as S +from ._replies import reply_db_error + def register_economy_income_commands( tree: app_commands.CommandTree, @@ -25,6 +27,9 @@ def register_economy_income_commands( await interaction.response.defer() res = await economy.do_daily(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.followup.send(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": @@ -63,6 +68,9 @@ def register_economy_income_commands( await interaction.response.defer() res = await economy.do_work(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.followup.send(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": @@ -97,6 +105,9 @@ def register_economy_income_commands( await interaction.response.defer() res = await economy.do_beg(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.followup.send(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": @@ -128,6 +139,9 @@ def register_economy_income_commands( await interaction.response.defer() res = await economy.do_crime(interaction.user.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.followup.send(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": @@ -187,6 +201,9 @@ def register_economy_income_commands( await interaction.response.defer() res = await economy.do_rob(interaction.user.id, sihtmärk.id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.followup.send(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": diff --git a/commands/economy_prestige_commands.py b/commands/economy_prestige_commands.py index f90af46..3a4b217 100644 --- a/commands/economy_prestige_commands.py +++ b/commands/economy_prestige_commands.py @@ -9,6 +9,8 @@ from discord import app_commands from core import economy import strings as S +from ._replies import reply_db_error + def register_prestige_commands( tree: app_commands.CommandTree, @@ -127,6 +129,9 @@ def register_prestige_commands( return await interaction.response.defer() res = await economy.do_prestige(self.user_id) + if not res["ok"] and res.get("reason") == "db_error": + await reply_db_error(interaction) + return self.clear_items() if not res["ok"]: embed = discord.Embed( @@ -166,6 +171,9 @@ def register_prestige_commands( await interaction.response.defer() res = await economy.do_prestige_buy(self.user_id, upgrade_id) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "insufficient_pp": err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"]) elif res["reason"] == "maxed": @@ -221,6 +229,9 @@ def register_prestige_commands( return res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower()) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "not_found": diff --git a/commands/economy_support_commands.py b/commands/economy_support_commands.py index 76778bd..60862ae 100644 --- a/commands/economy_support_commands.py +++ b/commands/economy_support_commands.py @@ -8,6 +8,8 @@ from discord import app_commands from core import economy import strings as S +from ._replies import reply_db_error + def register_economy_support_commands( tree: app_commands.CommandTree, @@ -47,6 +49,9 @@ def register_economy_support_commands( res = await economy.do_give(interaction.user.id, self._view.requester.id, amount) if not res["ok"]: self._view.remaining += amount # roll back the reservation + if res["reason"] == "db_error": + await reply_db_error(interaction) + return data = await economy.get_user(interaction.user.id) await interaction.response.send_message( S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True @@ -212,6 +217,9 @@ def register_economy_support_commands( res = await economy.do_buy_consumable(interaction.user.id, ese.value) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "insufficient": @@ -288,6 +296,9 @@ def register_economy_support_commands( res = await economy.do_vanity_select(interaction.user.id, ese.value) if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + return if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "insufficient": diff --git a/core/economy/fishing.py b/core/economy/fishing.py index 970a54a..1e44752 100644 --- a/core/economy/fishing.py +++ b/core/economy/fishing.py @@ -3,12 +3,11 @@ from __future__ import annotations import random -from datetime import timedelta from ..pb_client import DatabaseError from .store import ( - COOLDOWNS, _cooldown_remaining, _commit, _is_jailed, _locked_by, _now, - _prestige_mult, _txn, get_user, + _cooldown_remaining, _commit, _is_jailed, _locked_by, _now, + _prestige_mult, _txn, effective_cooldown, get_user, ) @@ -81,7 +80,7 @@ async def do_fish_start(user_id: int) -> dict: if jail := _is_jailed(user): return {"ok": False, "reason": "jailed", "remaining": jail} - fish_cd = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"] + fish_cd = effective_cooldown("fish", user["items"]) if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} diff --git a/core/economy/income.py b/core/economy/income.py index 8c5f265..92698e9 100644 --- a/core/economy/income.py +++ b/core/economy/income.py @@ -3,15 +3,16 @@ from __future__ import annotations import random -from datetime import date, timedelta +from datetime import date import strings from ..pb_client import DatabaseError from . import house from .store import ( - COOLDOWNS, JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining, - _is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user, + JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining, + _is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, effective_cooldown, + get_user, ) from .house import _credit_house from .consumables import earn_mult @@ -29,7 +30,7 @@ async def do_daily(user_id: int) -> dict: if user.get("eco_banned"): return {"ok": False, "reason": "banned"} - daily_cd = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"] + daily_cd = effective_cooldown("daily", user["items"]) if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} @@ -109,7 +110,7 @@ async def do_work(user_id: int) -> dict: if user.get("eco_banned"): return {"ok": False, "reason": "banned"} - work_cd = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"] + work_cd = effective_cooldown("work", user["items"]) if cd := _cooldown_remaining(user, "work", override_cd=work_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} if jail := _is_jailed(user): @@ -163,7 +164,7 @@ async def do_beg(user_id: int) -> dict: if user.get("eco_banned"): return {"ok": False, "reason": "banned"} - beg_cd = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"] + beg_cd = effective_cooldown("beg", user["items"]) if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} diff --git a/core/economy/leaderboards.py b/core/economy/leaderboards.py index 7b963fa..55be52f 100644 --- a/core/economy/leaderboards.py +++ b/core/economy/leaderboards.py @@ -80,3 +80,32 @@ async def get_leaderboard_fish(top_n: int | None = 10) -> list[tuple[str, int]]: 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))], + } diff --git a/core/economy/store.py b/core/economy/store.py index 7742334..d1f8e5d 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -87,6 +87,26 @@ COOLDOWNS: dict[str, timedelta] = { "fish": timedelta(minutes=2), } +# Items that shorten a command's cooldown: command -> (item_id, reduced cooldown). +# Single source of truth so the cooldown check (do_*), the reminder scheduler +# (_maybe_remind) and the restart restore (_restore_reminders) never drift apart. +ITEM_COOLDOWNS: dict[str, tuple[str, timedelta]] = { + "work": ("monitor", timedelta(minutes=40)), + "beg": ("hiirematt", timedelta(minutes=3)), + "daily": ("korvaklapid", timedelta(hours=18)), + "fish": ("ussipurk", timedelta(seconds=90)), +} + + +def effective_cooldown(cmd: str, items) -> timedelta | None: + """The cooldown for `cmd` given the user's owned `items`, applying any + item-based reduction. Returns None for commands with no cooldown.""" + override = ITEM_COOLDOWNS.get(cmd) + if override is not None and override[0] in items: + return override[1] + return COOLDOWNS.get(cmd) + + JAIL_DURATION = timedelta(minutes=30) HEIST_JAIL = timedelta(hours=1, minutes=30) diff --git a/strings/common.py b/strings/common.py index 99b3b5f..4308af8 100644 --- a/strings/common.py +++ b/strings/common.py @@ -176,6 +176,7 @@ ERR: dict[str, str] = { "sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```", "gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.", "payout_failed": "⚠️ Tehniline viga võidu väljamaksmisel - see on logitud ja admin taastab su TipiCOINid. Vabandame!", + "db_error": "⚠️ Andmebaas ei vasta praegu. Proovi hetke pärast uuesti.", } # --------------------------------------------------------------------------- diff --git a/tests/test_economy_pure.py b/tests/test_economy_pure.py index 5d72d30..09682cc 100644 --- a/tests/test_economy_pure.py +++ b/tests/test_economy_pure.py @@ -35,6 +35,29 @@ class TestLevels: assert economy.level_role_name(99) == "TipiLEGEND" +class TestEffectiveCooldown: + def test_default_when_no_item(self): + assert economy.effective_cooldown("work", []) == economy.COOLDOWNS["work"] + assert economy.effective_cooldown("daily", []) == economy.COOLDOWNS["daily"] + + def test_item_reduces_cooldown(self): + assert economy.effective_cooldown("work", ["monitor"]) == timedelta(minutes=40) + assert economy.effective_cooldown("beg", ["hiirematt"]) == timedelta(minutes=3) + assert economy.effective_cooldown("daily", ["korvaklapid"]) == timedelta(hours=18) + assert economy.effective_cooldown("fish", ["ussipurk"]) == timedelta(seconds=90) + + def test_unrelated_item_does_not_reduce(self): + assert economy.effective_cooldown("work", ["hiirematt"]) == economy.COOLDOWNS["work"] + + def test_command_without_cooldown_returns_none(self): + assert economy.effective_cooldown("unknown", []) is None + + def test_every_item_cooldown_beats_its_base(self): + # An item-reduced cooldown must always be shorter than the base. + for cmd, (item, reduced) in economy.store.ITEM_COOLDOWNS.items(): + assert reduced < economy.COOLDOWNS[cmd] + + class TestGambleExp: def test_tiers(self): assert economy.gamble_exp(0) == 0 diff --git a/tests/test_leaderboards.py b/tests/test_leaderboards.py new file mode 100644 index 0000000..f150442 --- /dev/null +++ b/tests/test_leaderboards.py @@ -0,0 +1,49 @@ +"""Tests for leaderboard queries, incl. the single-scan combined builder.""" + +from core import economy, pb_client + +from conftest import run + + +def _seed(fake_pb, n: int) -> None: + for i in range(1, n + 1): + run(economy.get_user(1000 + i)) + rec = fake_pb.record_for(1000 + i) + rec["balance"] = i * 100 + rec["exp"] = i * 50 + rec["season_total_exp"] = i * 10 + rec["prestige_level"] = i % 3 + rec["prestige_points"] = i + rec["total_wagered"] = i * 7 + rec["total_fish_caught"] = n - i # inverse order, to catch sort mistakes + + +class TestGetAllLeaderboards: + def test_matches_individual_queries(self, fake_pb): + _seed(fake_pb, 5) + combined = run(economy.get_all_leaderboards()) + assert combined["coins"] == run(economy.get_leaderboard(top_n=None)) + assert combined["exp"] == run(economy.get_leaderboard_exp(top_n=None)) + assert combined["season"] == run(economy.get_leaderboard_season_exp(top_n=None)) + assert combined["prestige"] == run(economy.get_leaderboard_prestige(top_n=None)) + assert combined["wagered"] == run(economy.get_leaderboard_wagered(top_n=None)) + assert combined["fish"] == run(economy.get_leaderboard_fish(top_n=None)) + + def test_scans_collection_once(self, fake_pb, monkeypatch): + _seed(fake_pb, 3) + calls = {"n": 0} + real = pb_client.list_all_records + + async def counting(): + calls["n"] += 1 + return await real() + + monkeypatch.setattr(pb_client, "list_all_records", counting) + run(economy.get_all_leaderboards()) + assert calls["n"] == 1 # not six + + def test_fish_sorted_descending(self, fake_pb): + _seed(fake_pb, 4) + fish = run(economy.get_all_leaderboards())["fish"] + counts = [c for _, c in fish] + assert counts == sorted(counts, reverse=True)