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

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

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