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
112 lines
5.0 KiB
Python
112 lines
5.0 KiB
Python
"""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"]}
|