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:
Rene Arumetsa
2026-09-04 02:57:34 +03:00
parent 42bbdbd93d
commit 570d8b7cbd
9 changed files with 261 additions and 2 deletions

View File

@@ -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. | | `/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. | | `/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. | | `/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). | | `/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. | | `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. |
| `/fishsell` | Sell all fish currently in your inventory at once. | | `/fishsell` | Sell all fish currently in your inventory at once. |

View File

@@ -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_profile_commands( def register_economy_profile_commands(
tree: app_commands.CommandTree, tree: app_commands.CommandTree,
@@ -342,6 +344,53 @@ def register_economy_profile_commands(
data = await economy.get_user(target.id) data = await economy.get_user(target.id)
await interaction.response.send_message(embed=_balance_embed(target, data)) 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"]) @tree.command(name="cooldowns", description=S.CMD["cooldowns"])
async def cmd_cooldowns(interaction: discord.Interaction): async def cmd_cooldowns(interaction: discord.Interaction):
data = await economy.get_user(interaction.user.id) data = await economy.get_user(interaction.user.id)

View File

@@ -31,9 +31,11 @@ from .gambling import *
from .prestige import * from .prestige import *
from .leaderboards import * from .leaderboards import *
from .heist import * from .heist import *
from .achievements import *
from .admin import * from .admin import *
from . import ( # noqa: E402 (submodules addressable as economy.store etc.) from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
admin, bank, consumables, fishing, gambling, heist, house, income, jail, achievements, admin, bank, consumables, fishing, gambling, heist, house,
leaderboards, levels, lootbox, prestige, quests, shop, store, vanity, income, jail, leaderboards, levels, lootbox, prestige, quests, shop, store,
vanity,
) )

View 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"]}

View File

@@ -154,6 +154,7 @@ class UserData(TypedDict, total=False):
total_received: int total_received: int
best_daily_streak: int best_daily_streak: int
lootboxes_opened: int lootboxes_opened: int
achievements_earned: list # ids of achievements already claimed
heist_global_cd_until: float heist_global_cd_until: float
# Prestige system # Prestige system
prestige_level: int prestige_level: int
@@ -217,6 +218,7 @@ def _default_user() -> UserData:
"total_received": 0, "total_received": 0,
"best_daily_streak": 0, "best_daily_streak": 0,
"lootboxes_opened": 0, "lootboxes_opened": 0,
"achievements_earned": [],
"heist_global_cd_until": 0.0, "heist_global_cd_until": 0.0,
# ── Prestige ───────────────────────────────────────────────────────── # ── Prestige ─────────────────────────────────────────────────────────
"prestige_level": 0, "prestige_level": 0,

View File

@@ -61,6 +61,7 @@ from .economy import (
VANITY_UI, VANITY_UI,
LOOTBOX_UI, LOOTBOX_UI,
BANK_UI, BANK_UI,
ACHIEVEMENTS_UI,
JAILED_UI, JAILED_UI,
SHOP_BTN, SHOP_BTN,
DAILY_UI, DAILY_UI,
@@ -156,6 +157,7 @@ __all__ = [
'VANITY_UI', 'VANITY_UI',
'LOOTBOX_UI', 'LOOTBOX_UI',
'BANK_UI', 'BANK_UI',
'ACHIEVEMENTS_UI',
'JAILED_UI', 'JAILED_UI',
'SHOP_BTN', 'SHOP_BTN',
'DAILY_UI', 'DAILY_UI',

View File

@@ -76,6 +76,7 @@ CMD: dict[str, str] = {
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)", "consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
"vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid", "vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid",
"lootbox": "Ava õnnekast - juhuslik auhind müntide või boonuse näol", "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", "bank": "Vaata oma panka - röövikindel hoius",
"deposit": "Pane münte panka (röövikindel, aga ei teeni intressi)", "deposit": "Pane münte panka (röövikindel, aga ei teeni intressi)",
"withdraw": "Võta münte pangast rahakotti", "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)."), ("/deposit <amount>", "Pane münte panka (röövikindel hoius, ei teeni intressi)."),
("/withdraw <amount>", "Võta münte pangast rahakotti."), ("/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)."), ("/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?"), ("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
("/shop", "Sirvi TipiBOTi poodi"), ("/shop", "Sirvi TipiBOTi poodi"),
("/buy <item>", "Osta ese TipiBOTi poodist"), ("/buy <item>", "Osta ese TipiBOTi poodist"),

View File

@@ -20,6 +20,7 @@ __all__ = [
'VANITY_UI', 'VANITY_UI',
'LOOTBOX_UI', 'LOOTBOX_UI',
'BANK_UI', 'BANK_UI',
'ACHIEVEMENTS_UI',
'JAILED_UI', 'JAILED_UI',
'SHOP_BTN', 'SHOP_BTN',
'DAILY_UI', 'DAILY_UI',
@@ -290,6 +291,17 @@ BANK_UI: dict[str, str] = {
"nothing_bank": "❌ Sul pole nii palju raha pangas.", "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] = { JAILED_UI: dict[str, str] = {
"title": "🔒 Praegu vanglas", "title": "🔒 Praegu vanglas",
"empty": "Kõik on vabad! Vanglas pole kedagi.", "empty": "Kõik on vabad! Vanglas pole kedagi.",

View 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"]