forked from sass/tipibot
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
This commit is contained in:
24
bot.py
24
bot.py
@@ -772,17 +772,7 @@ async def _restore_reminders() -> None:
|
|||||||
last_str = user.get(last_key)
|
last_str = user.get(last_key)
|
||||||
if not last_str:
|
if not last_str:
|
||||||
continue
|
continue
|
||||||
items = user.get("items", [])
|
cooldown = economy.effective_cooldown(cmd, 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)
|
|
||||||
if not cooldown:
|
if not cooldown:
|
||||||
continue
|
continue
|
||||||
last_dt = datetime.datetime.fromisoformat(last_str)
|
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)
|
user_data = await economy.get_user(user_id)
|
||||||
if cmd not in user_data.get("reminders", []):
|
if cmd not in user_data.get("reminders", []):
|
||||||
return
|
return
|
||||||
items = set(user_data.get("items", []))
|
delay = economy.effective_cooldown(cmd, user_data.get("items", [])) or datetime.timedelta(hours=1)
|
||||||
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))
|
|
||||||
_schedule_reminder(user_id, cmd, delay)
|
_schedule_reminder(user_id, cmd, delay)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
25
commands/_replies.py
Normal file
25
commands/_replies.py
Normal file
@@ -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
|
||||||
@@ -13,6 +13,8 @@ from core import economy
|
|||||||
from core.emoji import EMOJI as E
|
from core.emoji import EMOJI as E
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_economy_extra_commands(
|
def register_economy_extra_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -120,6 +122,9 @@ def register_economy_extra_commands(
|
|||||||
return
|
return
|
||||||
res = await economy.do_heist_check(interaction.user.id)
|
res = await economy.do_heist_check(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
@@ -270,6 +275,9 @@ def register_economy_extra_commands(
|
|||||||
return
|
return
|
||||||
res = await economy.do_heist_check(interaction.user.id)
|
res = await economy.do_heist_check(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "jailed":
|
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)
|
res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
@@ -751,14 +762,13 @@ def register_economy_extra_commands(
|
|||||||
@tree.command(name="leaderboard", description=S.CMD["leaderboard"])
|
@tree.command(name="leaderboard", description=S.CMD["leaderboard"])
|
||||||
async def cmd_leaderboard(interaction: discord.Interaction):
|
async def cmd_leaderboard(interaction: discord.Interaction):
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather(
|
lbs = await economy.get_all_leaderboards() # single collection scan for all six tabs
|
||||||
economy.get_leaderboard(top_n=None),
|
coins_raw = lbs["coins"]
|
||||||
economy.get_leaderboard_exp(top_n=None),
|
exp_raw = lbs["exp"]
|
||||||
economy.get_leaderboard_season_exp(top_n=None),
|
season_raw = lbs["season"]
|
||||||
economy.get_leaderboard_prestige(top_n=None),
|
prestige_raw = lbs["prestige"]
|
||||||
economy.get_leaderboard_wagered(top_n=None),
|
wagered_raw = lbs["wagered"]
|
||||||
economy.get_leaderboard_fish(top_n=None),
|
fish_raw = lbs["fish"]
|
||||||
)
|
|
||||||
|
|
||||||
house_entry = None
|
house_entry = None
|
||||||
regular = []
|
regular = []
|
||||||
@@ -865,6 +875,9 @@ def register_economy_extra_commands(
|
|||||||
async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]):
|
async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]):
|
||||||
res = await economy.do_buy(interaction.user.id, ese.value)
|
res = await economy.do_buy(interaction.user.id, ese.value)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "owned":
|
elif res["reason"] == "owned":
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from discord import app_commands
|
|||||||
from core import economy
|
from core import economy
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_economy_fish_commands(
|
def register_economy_fish_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -218,6 +220,9 @@ def register_economy_fish_commands(
|
|||||||
|
|
||||||
res = await economy.do_fish_start(interaction.user.id)
|
res = await economy.do_fish_start(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from core import economy
|
|||||||
from core.emoji import EMOJI as E
|
from core.emoji import EMOJI as E
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_economy_games_commands(
|
def register_economy_games_commands(
|
||||||
tree: app_commands.CommandTree,
|
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)
|
res = await economy.do_roulette(interaction.user.id, panus_int, värv.value)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
active_games.discard(interaction.user.id)
|
active_games.discard(interaction.user.id)
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
@@ -202,7 +207,14 @@ def register_economy_games_commands(
|
|||||||
bet_line = ""
|
bet_line = ""
|
||||||
if self.bet > 0:
|
if self.bet > 0:
|
||||||
res = await economy.do_game_bet(interaction.user.id, self.bet, outcome)
|
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"]))
|
bet_line = S.RPS_UI["bet_win"].format(amount=coin(self.bet), balance=coin(res["balance"]))
|
||||||
elif outcome == "lose":
|
elif outcome == "lose":
|
||||||
bet_line = S.RPS_UI["bet_lose"].format(amount=coin(self.bet), balance=coin(res["balance"]))
|
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)
|
res = await economy.do_slots(interaction.user.id, panus_int)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
active_games.discard(interaction.user.id)
|
active_games.discard(interaction.user.id)
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
return
|
return
|
||||||
@@ -1017,6 +1032,9 @@ def register_economy_games_commands(
|
|||||||
try:
|
try:
|
||||||
res = await economy.do_blackjack_bet(self.user_id, self.bet)
|
res = await economy.do_blackjack_bet(self.user_id, self.bet)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
|
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
|
||||||
)
|
)
|
||||||
@@ -1042,6 +1060,9 @@ def register_economy_games_commands(
|
|||||||
try:
|
try:
|
||||||
res = await economy.do_blackjack_bet(self.user_id, self.bet)
|
res = await economy.do_blackjack_bet(self.user_id, self.bet)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
|
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)
|
res = await economy.do_blackjack_bet(interaction.user.id, bet)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "jailed":
|
elif res["reason"] == "jailed":
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from discord import app_commands
|
|||||||
from core import economy
|
from core import economy
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_economy_income_commands(
|
def register_economy_income_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -25,6 +27,9 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_daily(interaction.user.id)
|
res = await economy.do_daily(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
@@ -63,6 +68,9 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_work(interaction.user.id)
|
res = await economy.do_work(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
@@ -97,6 +105,9 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_beg(interaction.user.id)
|
res = await economy.do_beg(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
@@ -128,6 +139,9 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_crime(interaction.user.id)
|
res = await economy.do_crime(interaction.user.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
@@ -187,6 +201,9 @@ def register_economy_income_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_rob(interaction.user.id, sihtmärk.id)
|
res = await economy.do_rob(interaction.user.id, sihtmärk.id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
await interaction.followup.send(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "cooldown":
|
elif res["reason"] == "cooldown":
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from discord import app_commands
|
|||||||
from core import economy
|
from core import economy
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_prestige_commands(
|
def register_prestige_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -127,6 +129,9 @@ def register_prestige_commands(
|
|||||||
return
|
return
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_prestige(self.user_id)
|
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()
|
self.clear_items()
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
@@ -166,6 +171,9 @@ def register_prestige_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
res = await economy.do_prestige_buy(self.user_id, upgrade_id)
|
res = await economy.do_prestige_buy(self.user_id, upgrade_id)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "insufficient_pp":
|
if res["reason"] == "insufficient_pp":
|
||||||
err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"])
|
err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"])
|
||||||
elif res["reason"] == "maxed":
|
elif res["reason"] == "maxed":
|
||||||
@@ -221,6 +229,9 @@ def register_prestige_commands(
|
|||||||
return
|
return
|
||||||
res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower())
|
res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower())
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "not_found":
|
elif res["reason"] == "not_found":
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from discord import app_commands
|
|||||||
from core import economy
|
from core import economy
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
|
||||||
def register_economy_support_commands(
|
def register_economy_support_commands(
|
||||||
tree: app_commands.CommandTree,
|
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)
|
res = await economy.do_give(interaction.user.id, self._view.requester.id, amount)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
self._view.remaining += amount # roll back the reservation
|
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)
|
data = await economy.get_user(interaction.user.id)
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True
|
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)
|
res = await economy.do_buy_consumable(interaction.user.id, ese.value)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "insufficient":
|
elif res["reason"] == "insufficient":
|
||||||
@@ -288,6 +296,9 @@ def register_economy_support_commands(
|
|||||||
|
|
||||||
res = await economy.do_vanity_select(interaction.user.id, ese.value)
|
res = await economy.do_vanity_select(interaction.user.id, ese.value)
|
||||||
if not res["ok"]:
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
return
|
||||||
if res["reason"] == "banned":
|
if res["reason"] == "banned":
|
||||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
elif res["reason"] == "insufficient":
|
elif res["reason"] == "insufficient":
|
||||||
|
|||||||
@@ -3,12 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from ..pb_client import DatabaseError
|
from ..pb_client import DatabaseError
|
||||||
from .store import (
|
from .store import (
|
||||||
COOLDOWNS, _cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
|
_cooldown_remaining, _commit, _is_jailed, _locked_by, _now,
|
||||||
_prestige_mult, _txn, get_user,
|
_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):
|
if jail := _is_jailed(user):
|
||||||
return {"ok": False, "reason": "jailed", "remaining": jail}
|
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):
|
if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd):
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from datetime import date, timedelta
|
from datetime import date
|
||||||
|
|
||||||
import strings
|
import strings
|
||||||
|
|
||||||
from ..pb_client import DatabaseError
|
from ..pb_client import DatabaseError
|
||||||
from . import house
|
from . import house
|
||||||
from .store import (
|
from .store import (
|
||||||
COOLDOWNS, JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
|
JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining,
|
||||||
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user,
|
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, effective_cooldown,
|
||||||
|
get_user,
|
||||||
)
|
)
|
||||||
from .house import _credit_house
|
from .house import _credit_house
|
||||||
from .consumables import earn_mult
|
from .consumables import earn_mult
|
||||||
@@ -29,7 +30,7 @@ async def do_daily(user_id: int) -> dict:
|
|||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "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):
|
if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd):
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": 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"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "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):
|
if cd := _cooldown_remaining(user, "work", override_cd=work_cd):
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||||
if jail := _is_jailed(user):
|
if jail := _is_jailed(user):
|
||||||
@@ -163,7 +164,7 @@ async def do_beg(user_id: int) -> dict:
|
|||||||
if user.get("eco_banned"):
|
if user.get("eco_banned"):
|
||||||
return {"ok": False, "reason": "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):
|
if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd):
|
||||||
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
return {"ok": False, "reason": "cooldown", "remaining": cd}
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,32 @@ async def get_leaderboard_fish(top_n: int | None = 10) -> list[tuple[str, int]]:
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
return result if top_n is None else result[:top_n]
|
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))],
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,26 @@ COOLDOWNS: dict[str, timedelta] = {
|
|||||||
"fish": timedelta(minutes=2),
|
"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)
|
JAIL_DURATION = timedelta(minutes=30)
|
||||||
HEIST_JAIL = timedelta(hours=1, minutes=30)
|
HEIST_JAIL = timedelta(hours=1, minutes=30)
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ ERR: dict[str, str] = {
|
|||||||
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
|
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
|
||||||
"gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.",
|
"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!",
|
"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.",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -35,6 +35,29 @@ class TestLevels:
|
|||||||
assert economy.level_role_name(99) == "TipiLEGEND"
|
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:
|
class TestGambleExp:
|
||||||
def test_tiers(self):
|
def test_tiers(self):
|
||||||
assert economy.gamble_exp(0) == 0
|
assert economy.gamble_exp(0) == 0
|
||||||
|
|||||||
49
tests/test_leaderboards.py
Normal file
49
tests/test_leaderboards.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user