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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user