diff --git a/README.md b/README.md index 2eb5769..2ecae2b 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e | `/vanity` | Status shop — buy and equip cosmetic badges/titles (shown on `/profile`). Coins are **burned**, not recirculated. No gameplay effect. | | `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. | | `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. | +| `/achievements` | Milestone badges over your lifetime stats (work/wealth/gambling/crime/fishing/streaks/prestige). Each unlocks once and pays a one-time coin reward; opening the command claims any newly earned. | | `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). | | `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. | | `/fishsell` | Sell all fish currently in your inventory at once. | diff --git a/commands/economy_profile_commands.py b/commands/economy_profile_commands.py index 7a1a256..9fbd4d2 100644 --- a/commands/economy_profile_commands.py +++ b/commands/economy_profile_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_profile_commands( tree: app_commands.CommandTree, @@ -342,6 +344,53 @@ def register_economy_profile_commands( data = await economy.get_user(target.id) await interaction.response.send_message(embed=_balance_embed(target, data)) + @tree.command(name="achievements", description=S.CMD["achievements"]) + async def cmd_achievements( + interaction: discord.Interaction, kasutaja: discord.Member | None = None + ): + target = kasutaja or interaction.user + note = "" + # Only the invoker's own view claims newly-earned achievements. + if target.id == interaction.user.id: + res = await economy.do_check_achievements(interaction.user.id) + if not res["ok"]: + await reply_db_error(interaction) + return + if res["new"]: + names = ", ".join( + f"{economy.ACHIEVEMENTS[a]['emoji']} {economy.ACHIEVEMENTS[a]['name']}" + for a in res["new"] + ) + note = S.ACHIEVEMENTS_UI["unlocked_note"].format( + names=names, reward=coin(res["reward"]) + ) + "\n\n" + + data = await economy.get_user(target.id) + rows = economy.achievements_view(data) + earned_count = sum(1 for r in rows if r["earned"]) + lines = [ + S.ACHIEVEMENTS_UI["row_earned"].format( + emoji=r["emoji"], name=r["name"], reward=coin(r["reward"]) + ) + if r["earned"] + else S.ACHIEVEMENTS_UI["row_locked"].format( + emoji=r["emoji"], name=r["name"], progress=r["progress"], + goal=r["goal"], reward=coin(r["reward"]), + ) + for r in rows + ] + title = S.ACHIEVEMENTS_UI["title"] + if target.id != interaction.user.id: + title += f" · {target.display_name}" + embed = discord.Embed( + title=title, + description=note + + S.ACHIEVEMENTS_UI["desc"].format(earned=earned_count, total=len(rows)) + + "\n\n" + "\n".join(lines), + color=0xF4C430, + ) + await interaction.response.send_message(embed=embed) + @tree.command(name="cooldowns", description=S.CMD["cooldowns"]) async def cmd_cooldowns(interaction: discord.Interaction): data = await economy.get_user(interaction.user.id) diff --git a/core/economy/__init__.py b/core/economy/__init__.py index 1f2a941..73f5b71 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -31,9 +31,11 @@ from .gambling import * from .prestige import * from .leaderboards import * from .heist import * +from .achievements import * from .admin import * from . import ( # noqa: E402 (submodules addressable as economy.store etc.) - admin, bank, consumables, fishing, gambling, heist, house, income, jail, - leaderboards, levels, lootbox, prestige, quests, shop, store, vanity, + achievements, admin, bank, consumables, fishing, gambling, heist, house, + income, jail, leaderboards, levels, lootbox, prestige, quests, shop, store, + vanity, ) diff --git a/core/economy/achievements.py b/core/economy/achievements.py new file mode 100644 index 0000000..194ad71 --- /dev/null +++ b/core/economy/achievements.py @@ -0,0 +1,111 @@ +"""Achievements: one-time milestone badges over the lifetime stat counters. + +Each achievement watches a monotonic stat the bot already tracks and unlocks once +that stat crosses a threshold, paying a modest one-time coin reward. Detection is +lazy: do_check_achievements is called when the player opens /achievements (like +quests roll on view), so no hook is needed on every command. Rewards are bounded +(each pays once) so this is a small, capped coin source. +""" + +from __future__ import annotations + +from typing import TypedDict + +from ..pb_client import DatabaseError +from .store import _commit, _locked_by, _txn, get_user + +__all__ = [ + "Achievement", + "ACHIEVEMENTS", + "newly_earned", + "achievements_view", + "do_check_achievements", +] + + +class Achievement(TypedDict): + stat: str # UserData counter this milestone watches + goal: int + name: str + emoji: str + reward: int # one-time coin payout + + +# Ordered roughly easy -> hard within each theme. Rewards scale with difficulty. +ACHIEVEMENTS: dict[str, Achievement] = { + # Grind + "work_10": {"stat": "work_count", "goal": 10, "name": "Töömesilane", "emoji": "🐝", "reward": 250}, + "work_100": {"stat": "work_count", "goal": 100, "name": "Töönarkomaan", "emoji": "🛠️", "reward": 1_500}, + "beg_50": {"stat": "beg_count", "goal": 50, "name": "Elukutseline kerjus", "emoji": "🥺", "reward": 500}, + # Wealth + "earned_50k": {"stat": "lifetime_earned", "goal": 50_000, "name": "Jõukas", "emoji": "💰", "reward": 1_000}, + "earned_500k": {"stat": "lifetime_earned", "goal": 500_000, "name": "TipiRIKAS", "emoji": "🤑", "reward": 5_000}, + # Gambling + "wager_10k": {"stat": "total_wagered", "goal": 10_000, "name": "Hasartmängur", "emoji": "🎲", "reward": 750}, + "wager_100k": {"stat": "total_wagered", "goal": 100_000, "name": "Kõrgete panuste mängija", "emoji": "🃏", "reward": 3_000}, + "jackpot_1": {"stat": "slots_jackpots", "goal": 1, "name": "Jackpot!", "emoji": "🎰", "reward": 1_000}, + # Crime & heists + "crime_25": {"stat": "crimes_succeeded", "goal": 25, "name": "Kurjategija", "emoji": "🦹", "reward": 1_000}, + "heist_5": {"stat": "heists_won", "goal": 5, "name": "Pangaröövel", "emoji": "💣", "reward": 1_500}, + # Fishing + "fish_25": {"stat": "total_fish_caught", "goal": 25, "name": "Kalur", "emoji": "🎣", "reward": 500}, + "fish_250": {"stat": "total_fish_caught", "goal": 250, "name": "Kalapüügimeister", "emoji": "🐟", "reward": 3_000}, + # Dedication + "streak_7": {"stat": "best_daily_streak", "goal": 7, "name": "Püsiv", "emoji": "🔥", "reward": 500}, + "streak_30": {"stat": "best_daily_streak", "goal": 30, "name": "Pühendunud", "emoji": "🗓️", "reward": 2_500}, + "prestige_1": {"stat": "prestige_level", "goal": 1, "name": "Taassünd", "emoji": "♻️", "reward": 2_000}, +} + + +def _earned_ids(user) -> set[str]: + return set(user.get("achievements_earned") or []) + + +def newly_earned(user) -> list[str]: + """Achievement ids whose threshold is met but which are not yet claimed.""" + earned = _earned_ids(user) + return [ + aid for aid, a in ACHIEVEMENTS.items() + if aid not in earned and int(user.get(a["stat"], 0) or 0) >= a["goal"] + ] + + +def achievements_view(user) -> list[dict]: + """Display rows for every achievement: progress + earned flag (ordered as + defined, earned last so unfinished goals surface first).""" + earned = _earned_ids(user) + rows = [] + for aid, a in ACHIEVEMENTS.items(): + prog = int(user.get(a["stat"], 0) or 0) + rows.append({ + "id": aid, "name": a["name"], "emoji": a["emoji"], + "goal": a["goal"], "reward": a["reward"], + "progress": min(prog, a["goal"]), "earned": aid in earned, + }) + rows.sort(key=lambda r: r["earned"]) # unearned first + return rows + + +@_locked_by(0) +async def do_check_achievements(user_id: int) -> dict: + """Claim any newly-earned achievements and pay their one-time rewards.""" + try: + user = await get_user(user_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + new = newly_earned(user) + if not new: + return {"ok": True, "new": [], "reward": 0, "balance": user["balance"]} + + earned = list(user.get("achievements_earned") or []) + total = 0 + for aid in new: + earned.append(aid) + total += ACHIEVEMENTS[aid]["reward"] + user["achievements_earned"] = earned + user["balance"] += total + user["lifetime_earned"] = user.get("lifetime_earned", 0) + total + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + await _commit(user_id, user) + _txn("ACHIEVEMENTS", user=user_id, unlocked=",".join(new), reward=f"+{total}", bal=user["balance"]) + return {"ok": True, "new": new, "reward": total, "balance": user["balance"]} diff --git a/core/economy/store.py b/core/economy/store.py index 10a0804..be07226 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -154,6 +154,7 @@ class UserData(TypedDict, total=False): total_received: int best_daily_streak: int lootboxes_opened: int + achievements_earned: list # ids of achievements already claimed heist_global_cd_until: float # Prestige system prestige_level: int @@ -217,6 +218,7 @@ def _default_user() -> UserData: "total_received": 0, "best_daily_streak": 0, "lootboxes_opened": 0, + "achievements_earned": [], "heist_global_cd_until": 0.0, # ── Prestige ───────────────────────────────────────────────────────── "prestige_level": 0, diff --git a/strings/__init__.py b/strings/__init__.py index 0e4ce64..1498f26 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -61,6 +61,7 @@ from .economy import ( VANITY_UI, LOOTBOX_UI, BANK_UI, + ACHIEVEMENTS_UI, JAILED_UI, SHOP_BTN, DAILY_UI, @@ -156,6 +157,7 @@ __all__ = [ 'VANITY_UI', 'LOOTBOX_UI', 'BANK_UI', + 'ACHIEVEMENTS_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', diff --git a/strings/commands.py b/strings/commands.py index 227dbb3..ea4a04e 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -76,6 +76,7 @@ CMD: dict[str, str] = { "consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)", "vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid", "lootbox": "Ava õnnekast - juhuslik auhind müntide või boonuse näol", + "achievements": "Vaata oma saavutusi ja teeni märkide eest ühekordseid preemiaid", "bank": "Vaata oma panka - röövikindel hoius", "deposit": "Pane münte panka (röövikindel, aga ei teeni intressi)", "withdraw": "Võta münte pangast rahakotti", @@ -159,6 +160,7 @@ HELP_CATEGORIES: dict[str, dict] = { ("/deposit ", "Pane münte panka (röövikindel hoius, ei teeni intressi)."), ("/withdraw ", "Võta münte pangast rahakotti."), ("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."), + ("/achievements", "Vaata oma saavutusi. Iga lukust lahti saanud märk annab ühekordse müntipreemia."), ("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"), ("/shop", "Sirvi TipiBOTi poodi"), ("/buy ", "Osta ese TipiBOTi poodist"), diff --git a/strings/economy.py b/strings/economy.py index 76be096..e4c448c 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -20,6 +20,7 @@ __all__ = [ 'VANITY_UI', 'LOOTBOX_UI', 'BANK_UI', + 'ACHIEVEMENTS_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', @@ -290,6 +291,17 @@ BANK_UI: dict[str, str] = { "nothing_bank": "❌ Sul pole nii palju raha pangas.", } +# --------------------------------------------------------------------------- +# Achievements (one-time milestone badges over lifetime stats) +# --------------------------------------------------------------------------- +ACHIEVEMENTS_UI: dict[str, str] = { + "title": "🏅 Saavutused", + "desc": "Iga lukust lahti saanud märk annab ühekordse müntipreemia.\nAvatud: **{earned}/{total}**", + "row_earned": "✅ {emoji} **{name}** · +{reward}", + "row_locked": "🔒 {emoji} {name} · {progress}/{goal} · +{reward}", + "unlocked_note": "🎉 **Uued saavutused avatud:** {names}\n💰 Preemia: +{reward}", +} + JAILED_UI: dict[str, str] = { "title": "🔒 Praegu vanglas", "empty": "Kõik on vabad! Vanglas pole kedagi.", diff --git a/tests/test_achievements.py b/tests/test_achievements.py new file mode 100644 index 0000000..c60d85e --- /dev/null +++ b/tests/test_achievements.py @@ -0,0 +1,78 @@ +"""Tests for the achievements system (one-time milestone rewards).""" + +from core import economy + +from conftest import run + +UID = 4747 + + +def _fund(fake_pb, **stats) -> None: + run(economy.get_user(UID)) + rec = fake_pb.record_for(UID) + rec.update(stats) + + +class TestNewlyEarned: + def test_threshold_met_is_newly_earned(self, fake_pb): + _fund(fake_pb, work_count=10) + user = run(economy.get_user(UID)) + assert "work_10" in economy.newly_earned(user) + + def test_below_threshold_not_earned(self, fake_pb): + _fund(fake_pb, work_count=9) + user = run(economy.get_user(UID)) + assert "work_10" not in economy.newly_earned(user) + + def test_already_claimed_not_repeated(self, fake_pb): + _fund(fake_pb, work_count=10, achievements_earned=["work_10"]) + user = run(economy.get_user(UID)) + assert "work_10" not in economy.newly_earned(user) + + +class TestClaim: + def test_claims_and_pays_reward_once(self, fake_pb): + _fund(fake_pb, work_count=10, balance=0) + reward = economy.ACHIEVEMENTS["work_10"]["reward"] + + res = run(economy.do_check_achievements(UID)) + assert res["ok"] and res["new"] == ["work_10"] + assert res["reward"] == reward + assert fake_pb.record_for(UID)["balance"] == reward + assert "work_10" in fake_pb.record_for(UID)["achievements_earned"] + + # Second call: nothing new, no double pay. + res2 = run(economy.do_check_achievements(UID)) + assert res2["new"] == [] and res2["reward"] == 0 + assert fake_pb.record_for(UID)["balance"] == reward + + def test_multiple_unlocked_at_once(self, fake_pb): + _fund(fake_pb, work_count=100, total_fish_caught=25, balance=0) + res = run(economy.do_check_achievements(UID)) + # work_10, work_100 and fish_25 all cross at once + assert set(res["new"]) == {"work_10", "work_100", "fish_25"} + expected = sum(economy.ACHIEVEMENTS[a]["reward"] for a in res["new"]) + assert res["reward"] == expected + assert fake_pb.record_for(UID)["balance"] == expected + + def test_nothing_to_claim(self, fake_pb): + _fund(fake_pb, balance=500) + res = run(economy.do_check_achievements(UID)) + assert res["ok"] and res["new"] == [] and res["reward"] == 0 + assert fake_pb.record_for(UID)["balance"] == 500 + + +class TestView: + def test_view_marks_earned_and_progress(self, fake_pb): + _fund(fake_pb, work_count=5, achievements_earned=["beg_50"]) + rows = economy.achievements_view(run(economy.get_user(UID))) + by_id = {r["id"]: r for r in rows} + assert by_id["beg_50"]["earned"] is True + assert by_id["work_10"]["earned"] is False + assert by_id["work_10"]["progress"] == 5 # capped at goal + assert len(rows) == len(economy.ACHIEVEMENTS) + + def test_progress_capped_at_goal(self, fake_pb): + _fund(fake_pb, work_count=9999) + rows = {r["id"]: r for r in economy.achievements_view(run(economy.get_user(UID)))} + assert rows["work_10"]["progress"] == rows["work_10"]["goal"]