master #3

Open
renkar wants to merge 90 commits from renkar/tipibot:master into master
15 changed files with 249 additions and 41 deletions
Showing only changes of commit eb7346b851 - Show all commits

24
bot.py
View File

@@ -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)

25
commands/_replies.py Normal file
View 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

View File

@@ -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":

View File

@@ -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":

View File

@@ -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":

View File

@@ -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":

View File

@@ -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":

View File

@@ -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":

View File

@@ -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}

View File

@@ -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}

View File

@@ -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))],
}

View File

@@ -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)

View File

@@ -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.",
}
# ---------------------------------------------------------------------------

View File

@@ -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

View 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)