forked from sass/tipibot
feat(economy): add /achievements milestone badges
15 one-time achievements over the lifetime stat counters (work/wealth/gambling/ crime/heists/fishing/streaks/prestige), each paying a modest one-time coin reward when unlocked. Detection is lazy - opening /achievements claims any newly earned (like quests roll on view) - so no per-command hook is needed, and rewards are a bounded, one-time coin source. - New achievements.py: ACHIEVEMENTS table, pure newly_earned/achievements_view, and locked do_check_achievements. New achievements_earned schema field. - /achievements command (own view claims; viewing others is read-only) with progress bars, db_error handling, and an "unlocked" banner. 8 tests: threshold detection, one-time claim (no double pay), multi-unlock, view progress capping. 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:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
111
core/economy/achievements.py
Normal file
111
core/economy/achievements.py
Normal file
@@ -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"]}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 <amount>", "Pane münte panka (röövikindel hoius, ei teeni intressi)."),
|
||||
("/withdraw <amount>", "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 <item>", "Osta ese TipiBOTi poodist"),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
78
tests/test_achievements.py
Normal file
78
tests/test_achievements.py
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user