"""TipiCOIN economy - data layer and business logic. Storage: PocketBase (see pb_client.py). Collection: economy_users. All public async functions are the single source of truth for mutations. """ from __future__ import annotations import logging import math import random from datetime import date, datetime, timedelta, timezone from typing import TypedDict import pb_client import strings _txn_log = logging.getLogger("tipiCOIN.txn") def _txn(event: str, **fields) -> None: """Log a single economy transaction to the transactions logger.""" body = " ".join(f"{k}={v}" for k, v in fields.items()) _txn_log.info("%-16s %s", event, body) # --------------------------------------------------------------------------- # Emoji config # To use your custom Discord emoji replace COIN with the full tag, e.g.: # COIN = "<:tipicoin:1234567890123456789>" # --------------------------------------------------------------------------- COIN = "<:TipiCOIN:1483000209188589628>" # --------------------------------------------------------------------------- # Shop catalogue # --------------------------------------------------------------------------- class ShopItem(TypedDict): name: str emoji: str cost: int description: str SHOP: dict[str, ShopItem] = { "gaming_hiir": { "name": "Mängurihiir", "emoji": "<:TipiHIIR:1483004306012504128>", "cost": 500, "description": strings.ITEM_DESCRIPTIONS["gaming_hiir"], }, "hiirematt": { "name": "Hiirematt", "emoji": "<:TipiMATT:1483387697132208128>", "cost": 600, "description": strings.ITEM_DESCRIPTIONS["hiirematt"], }, "korvaklapid": { "name": "K\u00f5rvaklapid", "emoji": "<:TipiKLAPID:1483387694083084349>", "cost": 1200, "description": strings.ITEM_DESCRIPTIONS["korvaklapid"], }, "lan_pass": { "name": "LAN pilet", "emoji": "<:TipiPILET:1483004308353060904>", "cost": 1200, "description": strings.ITEM_DESCRIPTIONS["lan_pass"], }, "energiajook": { "name": "Red Bull", "emoji": "<:TipiBULL:1483004310924300409>", "cost": 800, "description": strings.ITEM_DESCRIPTIONS["energiajook"], }, "gaming_laptop": { "name": "Bot Farm", "emoji": "<:TipiLAP:1483004307161874566>", "cost": 1500, "description": strings.ITEM_DESCRIPTIONS["gaming_laptop"], }, "anticheat": { "name": "Anticheat", "emoji": "<:TipiVAC:1483004309510819860>", "cost": 1000, "description": strings.ITEM_DESCRIPTIONS["anticheat"], }, # ----- Tier 2 ----- "reguleeritav_laud": { "name": "Reguleeritav laud", "emoji": "<:TipiLAUD:1483387695576125440>", "cost": 3500, "description": strings.ITEM_DESCRIPTIONS["reguleeritav_laud"], }, "jellyfin": { "name": "Jellyfin server", "emoji": "<:TipiSERVER:1483387701032910969>", "cost": 4000, "description": strings.ITEM_DESCRIPTIONS["jellyfin"], }, "mikrofon": { "name": "Eraldiseisev mikrofon", "emoji": "<:TipiMIC:1483387698499551313>", "cost": 2800, "description": strings.ITEM_DESCRIPTIONS["mikrofon"], }, "klaviatuur": { "name": "Mehaaniline klaviatuur", "emoji": "<:TipiKLAVA:1483014339228078140>", "cost": 1800, "description": strings.ITEM_DESCRIPTIONS["klaviatuur"], }, "monitor": { "name": "Ultralai monitor", "emoji": "<:TipiMONITOR:1483014340327243908>", "cost": 2500, "description": strings.ITEM_DESCRIPTIONS["monitor"], }, "cat6": { "name": "Cat6 kaabel", "emoji": "<:TipiCAT:1483014337663602718>", "cost": 3500, "description": strings.ITEM_DESCRIPTIONS["cat6"], }, # ----- Tier 3 ----- "monitor_360": { "name": "360Hz monitor", "emoji": "<:TipiMONITOR2:1483387699514839162>", "cost": 7500, "description": strings.ITEM_DESCRIPTIONS["monitor_360"], }, "karikas": { "name": "TipiLAN karikas", "emoji": "<:TipiKARIKAS:1483014841148112977>", "cost": 6000, "description": strings.ITEM_DESCRIPTIONS["karikas"], }, "gaming_tool": { "name": "Gaming tool", "emoji": "<:TipiTOOL:1483014341648187613>", "cost": 9000, "description": strings.ITEM_DESCRIPTIONS["gaming_tool"], }, } # Tier grouping (used by /shop pagination) SHOP_TIERS: dict[int, list[str]] = { 1: ["gaming_hiir", "hiirematt", "korvaklapid", "lan_pass", "energiajook", "anticheat", "gaming_laptop"], 2: ["reguleeritav_laud", "jellyfin", "mikrofon", "klaviatuur", "monitor", "cat6"], 3: ["monitor_360", "karikas", "gaming_tool"], } # Minimum level required to purchase Tier 2 / Tier 3 shop items SHOP_LEVEL_REQ: dict[str, int] = { "reguleeritav_laud": 10, "jellyfin": 10, "mikrofon": 10, "klaviatuur": 10, "monitor": 10, "cat6": 10, "monitor_360": 20, "karikas": 20, "gaming_tool": 20, } # --------------------------------------------------------------------------- # EXP / Level system # --------------------------------------------------------------------------- # EXP awarded per successful action EXP_REWARDS: dict[str, int] = { "daily": 50, "work": 25, "beg": 5, "crime_win": 15, "rob_win": 15, "gamble_win": 10, "heist_win": 25, } def gamble_exp(bet: int) -> int: """Scale EXP for a gambling win by bet size. Returns 0 for bets < 10 coins to close micro-bet EXP farming. 10-99 → 5, 100-999 → 10, 1 000-9 999 → 15, 10 000+ → 20, 100 000+ → 25 (cap). """ return min(25, max(0, int(math.log10(max(1, bet))) * 5)) ECONOMY_ROLE = "ECONOMY" # Vanity role milestones: (min_level, role_name) - highest first LEVEL_ROLES: list[tuple[int, str]] = [ (30, "TipiLEGEND"), (20, "TipiCHAD"), (10, "TipiHUSTLER"), (5, "TipiGRINDER"), (1, "TipiNOOB"), ] def get_level(exp: int) -> int: """Level = max(1, floor(sqrt(exp/10))). Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000.""" return max(1, int(math.sqrt(max(0, exp) / 10))) def exp_for_level(level: int) -> int: """Minimum cumulative EXP to reach this level.""" if level <= 1: return 0 return level * level * 10 def level_role_name(level: int) -> str: """Return the vanity role name for a given level.""" for threshold, name in LEVEL_ROLES: if level >= threshold: return name return LEVEL_ROLES[-1][1] # --------------------------------------------------------------------------- # Cooldowns # --------------------------------------------------------------------------- COOLDOWNS: dict[str, timedelta] = { "daily": timedelta(hours=20), "work": timedelta(hours=1), "beg": timedelta(minutes=5), "crime": timedelta(hours=2), "rob": timedelta(hours=2), } JAIL_DURATION = timedelta(minutes=30) HEIST_JAIL = timedelta(hours=1, minutes=30) # --------------------------------------------------------------------------- # User schema # --------------------------------------------------------------------------- class UserData(TypedDict, total=False): balance: int exp: int # lifetime EXP (resets each season) last_daily: str | None last_work: str | None last_beg: str | None last_crime: str | None last_rob: str | None last_heist: str | None daily_streak: int last_streak_date: str | None # ISO date "YYYY-MM-DD" items: list[str] item_uses: dict # {item_id: remaining_uses} for consumables jailed_until: str | None # ISO datetime or None jailbreak_used: bool reminders: list[str] # command names user wants DM reminders for eco_banned: bool # if True, user cannot use any economy commands # Lifetime statistics peak_balance: int lifetime_earned: int lifetime_lost: int work_count: int beg_count: int total_wagered: int biggest_win: int biggest_loss: int slots_jackpots: int crimes_attempted: int crimes_succeeded: int times_jailed: int total_bail_paid: int heists_joined: int heists_won: int total_given: int total_received: int best_daily_streak: int heist_global_cd_until: float def _default_user() -> UserData: return { "balance": 0, "exp": 0, "last_daily": None, "last_work": None, "last_beg": None, "last_crime": None, "last_rob": None, "last_heist": None, "daily_streak": 0, "last_streak_date": None, "items": [], "item_uses": {}, "jailed_until": None, "jailbreak_used": False, "reminders": ["daily", "work", "beg", "crime", "rob"], "eco_banned": False, # ── Lifetime stats ────────────────────────────────────────────────── "peak_balance": 0, "lifetime_earned": 0, "lifetime_lost": 0, "work_count": 0, "beg_count": 0, "total_wagered": 0, "biggest_win": 0, "biggest_loss": 0, "slots_jackpots": 0, "crimes_attempted": 0, "crimes_succeeded": 0, "times_jailed": 0, "total_bail_paid": 0, "heists_joined": 0, "heists_won": 0, "total_given": 0, "total_received": 0, "best_daily_streak": 0, "heist_global_cd_until": 0.0, } # --------------------------------------------------------------------------- # Persistence (PocketBase backend) # --------------------------------------------------------------------------- _log = logging.getLogger("tipiCOIN.economy") # --------------------------------------------------------------------------- # House account (bot user) # --------------------------------------------------------------------------- HOUSE_ID: int | None = None def set_house(user_id: int) -> None: """Register the bot's Discord user ID as the house account.""" global HOUSE_ID HOUSE_ID = user_id async def _credit_house(amount: int) -> None: """Add `amount` coins to the house account. No-op if house not set.""" if HOUSE_ID is None or amount <= 0: return user = await get_user(HOUSE_ID) user["balance"] = user.get("balance", 0) + amount await _commit(HOUSE_ID, user) async def get_heist_global_cd() -> float: """Return unix timestamp until which no new heist can start. Persisted on house record.""" if HOUSE_ID is None: return 0.0 house = await get_user(HOUSE_ID) return float(house.get("heist_global_cd_until") or 0) async def set_heist_global_cd(until: float) -> None: """Persist heist global cooldown expiry to the house account in PocketBase.""" if HOUSE_ID is None: return house = await get_user(HOUSE_ID) house["heist_global_cd_until"] = until await _commit(HOUSE_ID, house) async def do_spam_jail(user_id: int) -> None: """Jail a user for 30 minutes due to suspected automated command spam.""" user = await get_user(user_id) user["jailed_until"] = (_now() + timedelta(minutes=30)).isoformat() user["jailbreak_used"] = False user["times_jailed"] = user.get("times_jailed", 0) + 1 await _commit(user_id, user) _txn("SPAM_JAIL", user=user_id, until=user["jailed_until"]) # --------------------------------------------------------------------------- # Public helpers # --------------------------------------------------------------------------- async def get_all_users_raw() -> dict[str, "UserData"]: """Return a snapshot of all user records.""" records = await pb_client.list_all_records() result: dict[str, UserData] = {} for record in records: uid = record.get("user_id", "") if not uid: continue user = _default_user() for key in list(user.keys()): if key in record: user[key] = record[key] # type: ignore[literal-required] user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key] result[uid] = user return result async def migrate_anticheat_uses() -> int: """One-time migration: users who own anticheat but have no item_uses entry get 2 uses.""" records = await pb_client.list_all_records() changed = 0 for record in records: items = record.get("items") or [] item_uses = record.get("item_uses") or {} if "anticheat" in items and "anticheat" not in item_uses: item_uses["anticheat"] = 2 await pb_client.update_record(record["id"], {"item_uses": item_uses}) changed += 1 return changed async def migrate_reminders_default() -> int: """One-time migration: enable all reminders for users who have an empty list.""" _ALL_REMINDERS = ["daily", "work", "beg", "crime", "rob"] records = await pb_client.list_all_records() changed = 0 for record in records: reminders = record.get("reminders") if not reminders: await pb_client.update_record(record["id"], {"reminders": _ALL_REMINDERS}) changed += 1 return changed # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _now() -> datetime: return datetime.now(tz=timezone.utc) def _parse_dt(s: str | None) -> datetime | None: if not s: return None dt = datetime.fromisoformat(s) # Ensure timezone-aware return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) def _cooldown_remaining( user: UserData, action: str, override_cd: timedelta | None = None ) -> timedelta | None: """Return remaining cooldown, or None if the action is ready.""" last = _parse_dt(user.get(f"last_{action}")) if last is None: return None cd = override_cd if override_cd is not None else COOLDOWNS[action] remaining = cd - (_now() - last) return remaining if remaining.total_seconds() > 0 else None def _is_jailed(user: UserData) -> timedelta | None: """Return remaining jail time, or None if free.""" until = _parse_dt(user.get("jailed_until")) if until is None: return None remaining = until - _now() return remaining if remaining.total_seconds() > 0 else None def jailed_remaining(user: UserData) -> timedelta | None: """Public wrapper - return remaining jail time, or None if free.""" return _is_jailed(user) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def format_td(td: timedelta) -> str: """Human-readable timedelta: '1t 23m' / '45m 12s' / '8s'.""" total = int(td.total_seconds()) h, rem = divmod(total, 3600) m, s = divmod(rem, 60) if h: return f"{h}t {m}m" if m: return f"{m}m {s}s" return f"{s}s" async def get_user(user_id: int) -> UserData: """Fetch user data from PocketBase, creating a default record if first seen.""" uid = str(user_id) record = await pb_client.get_record(uid) if record is None: default = _default_user() default["user_id"] = uid # type: ignore[typeddict-unknown-key] record = await pb_client.create_record(default) user = _default_user() for key in list(user.keys()): if key in record: user[key] = record[key] # type: ignore[literal-required] user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key] return user async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]: """Return top_n (user_id_str, balance) pairs sorted descending.""" records = await pb_client.list_all_records() result = sorted( ((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")), key=lambda x: x[1], reverse=True, ) return result if top_n is None else result[:top_n] async def get_leaderboard_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]: """Return top_n (user_id_str, exp, level) sorted by EXP descending.""" records = await pb_client.list_all_records() result = sorted( ((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")), key=lambda x: x[1], reverse=True, ) entries = [(uid, exp, get_level(exp)) for uid, exp in result] return entries if top_n is None else entries[:top_n] async def award_exp(user_id: int, amount: int) -> dict: """Add EXP to a user. Returns old_level, new_level, total exp.""" user = await get_user(user_id) old_exp = user.get("exp", 0) new_exp = old_exp + amount old_level = get_level(old_exp) new_level = get_level(new_exp) user["exp"] = new_exp await _commit(user_id, user) return {"old_level": old_level, "new_level": new_level, "exp": new_exp} async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]: """Snapshot top_n by EXP, then full wipe: EXP, balance, items, item_uses. Returns top list (uid, exp, level) captured before the reset.""" records = await pb_client.list_all_records() top = sorted( ((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")), key=lambda x: x[1], reverse=True, )[:top_n] reset_fields = { "exp": 0, "balance": 0, "items": [], "item_uses": {}, "last_daily": None, "last_work": None, "last_beg": None, "last_crime": None, "last_rob": None, "daily_streak": 0, "last_streak_date": None, } for record in records: await pb_client.update_record(record["id"], reset_fields) return [(uid, exp, get_level(exp)) for uid, exp in top] # --------------------------------------------------------------------------- # Internal write helper # --------------------------------------------------------------------------- async def _commit(user_id: int, user: UserData) -> None: try: record_id = user.get("_pb_id") # type: ignore[typeddict-item] clean = {k: v for k, v in user.items() if k != "_pb_id"} clean["user_id"] = str(user_id) if record_id: await pb_client.update_record(record_id, clean) else: _log.warning("_commit for user %s had no _pb_id; creating new record", user_id) created = await pb_client.create_record(clean) user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key] except Exception as exc: _log.error("_commit failed for user %s: %s", user_id, exc) # --------------------------------------------------------------------------- # /daily # --------------------------------------------------------------------------- async def do_daily(user_id: int) -> dict: user = await get_user(user_id) if user.get("eco_banned"): return {"ok": False, "reason": "banned"} daily_cd = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"] if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} today = _now().date() last_str = user.get("last_streak_date") last_date = date.fromisoformat(last_str) if last_str else None if last_date is None: streak = 1 elif (today - last_date).days == 1: streak = user["daily_streak"] + 1 elif "karikas" in user["items"]: streak = user["daily_streak"] # karikas: streak survives missed days else: streak = 1 # streak broken # Streak multiplier tiers if streak >= 14: streak_mult = 3.0 elif streak >= 7: streak_mult = 2.0 elif streak >= 3: streak_mult = 1.5 else: streak_mult = 1.0 vip = "lan_pass" in user["items"] vip_mult = 2.0 if vip else 1.0 base = 150 earned = int(base * streak_mult * vip_mult) # Investor interest (capped at 500/day to prevent runaway wealth) interest = 0 if "gaming_laptop" in user["items"] and user["balance"] > 0: interest = min(int(user["balance"] * 0.05), 500) earned += interest user["balance"] += earned user["last_daily"] = _now().isoformat() user["daily_streak"] = streak user["last_streak_date"] = today.isoformat() user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned user["best_daily_streak"] = max(user.get("best_daily_streak", 0), streak) user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) await _commit(user_id, user) _txn("DAILY", user=user_id, earned=f"+{earned}", streak=streak, bal=user["balance"]) return { "ok": True, "earned": earned, "interest": interest, "streak": streak, "streak_mult": streak_mult, "vip": vip, "balance": user["balance"], } # --------------------------------------------------------------------------- # /work # --------------------------------------------------------------------------- _WORK_JOBS = strings.WORK_JOBS async def do_work(user_id: int) -> dict: user = await get_user(user_id) if user.get("eco_banned"): return {"ok": False, "reason": "banned"} work_cd = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"] if cd := _cooldown_remaining(user, "work", override_cd=work_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} if jail := _is_jailed(user): return {"ok": False, "reason": "jailed", "remaining": jail} job, job_mult = random.choice(_WORK_JOBS) base = random.randint(15, 75) worker_mult = 1.5 if "gaming_hiir" in user["items"] else 1.0 desk_mult = 1.25 if "reguleeritav_laud" in user["items"] else 1.0 lucky = False if "energiajook" in user["items"] and random.random() < 0.30: lucky = True earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0)) user["balance"] += earned user["last_work"] = _now().isoformat() user["work_count"] = user.get("work_count", 0) + 1 user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) await _commit(user_id, user) _txn("WORK", user=user_id, earned=f"+{earned}", lucky=lucky, bal=user["balance"]) return { "ok": True, "earned": earned, "job": job, "lucky": lucky, "hiir": worker_mult > 1.0, "laud": desk_mult > 1.0, "balance": user["balance"], } # --------------------------------------------------------------------------- # /beg # --------------------------------------------------------------------------- _BEG_LINES = strings.BEG_LINES _BEG_JAIL_LINES = strings.BEG_JAIL_LINES async def do_beg(user_id: int) -> dict: user = await get_user(user_id) if user.get("eco_banned"): return {"ok": False, "reason": "banned"} beg_cd = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"] if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} jailed = bool(_is_jailed(user)) beg_mult = 2 if "klaviatuur" in user["items"] else 1 earned = random.randint(10, 40) * beg_mult user["balance"] += earned user["last_beg"] = _now().isoformat() user["beg_count"] = user.get("beg_count", 0) + 1 user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) await _commit(user_id, user) _txn("BEG", user=user_id, earned=f"+{earned}", jailed=jailed, bal=user["balance"]) return { "ok": True, "earned": earned, "text": random.choice(_BEG_JAIL_LINES if jailed else _BEG_LINES), "klaviatuur": beg_mult > 1, "jailed": jailed, "balance": user["balance"], } # --------------------------------------------------------------------------- # /crime # --------------------------------------------------------------------------- _CRIME_WIN = strings.CRIME_WIN _CRIME_LOSE = strings.CRIME_LOSE async def do_crime(user_id: int) -> dict: user = await get_user(user_id) if user.get("eco_banned"): return {"ok": False, "reason": "banned"} if cd := _cooldown_remaining(user, "crime"): return {"ok": False, "reason": "cooldown", "remaining": cd} if jail := _is_jailed(user): return {"ok": False, "reason": "jailed", "remaining": jail} user["last_crime"] = _now().isoformat() win_chance = 0.75 if "cat6" in user["items"] else 0.60 user["crimes_attempted"] = user.get("crimes_attempted", 0) + 1 if random.random() < win_chance: earned = random.randint(200, 500) if "mikrofon" in user["items"]: earned = int(earned * 1.3) user["balance"] += earned user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1 user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) await _commit(user_id, user) _txn("CRIME_WIN", user=user_id, earned=f"+{earned}", bal=user["balance"]) return { "ok": True, "success": True, "earned": earned, "text": random.choice(_CRIME_WIN), "mikrofon": "mikrofon" in user["items"], "balance": user["balance"], } else: fine = random.randint(50, 150) user["balance"] = max(0, user["balance"] - fine) jailed = "gaming_tool" not in user["items"] if jailed: user["jailed_until"] = (_now() + JAIL_DURATION).isoformat() user["jailbreak_used"] = False user["times_jailed"] = user.get("times_jailed", 0) + 1 user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine await _commit(user_id, user) await _credit_house(fine) _txn("CRIME_FAIL", user=user_id, fine=f"-{fine}", jailed=jailed, bal=user["balance"]) return { "ok": True, "success": False, "fine": fine, "text": random.choice(_CRIME_LOSE), "jailed": jailed, "balance": user["balance"], } # --------------------------------------------------------------------------- # /jailbreak (Monopoly-style dice rolls) # --------------------------------------------------------------------------- async def set_jailbreak_used(user_id: int) -> None: """Mark that the user has consumed their dice attempt for this jail sentence.""" user = await get_user(user_id) user["jailbreak_used"] = True await _commit(user_id, user) async def do_jail_free(user_id: int) -> dict: """Remove jail status after rolling doubles.""" user = await get_user(user_id) user["jailed_until"] = None user["jailbreak_used"] = False await _commit(user_id, user) _txn("JAIL_FREE", user=user_id, method="doubles") return {"ok": True, "balance": user["balance"]} MIN_BAIL = 350 async def do_bail(user_id: int) -> dict: """Charge bail fine after exhausting jailbreak rolls and free the user. Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed.""" user = await get_user(user_id) if user["balance"] < MIN_BAIL: return {"ok": False, "reason": "broke", "balance": user["balance"]} pct = random.uniform(0.20, 0.30) fine = max(MIN_BAIL, int(user["balance"] * pct)) user["balance"] = max(0, user["balance"] - fine) user["jailed_until"] = None user["jailbreak_used"] = False user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine user["total_bail_paid"] = user.get("total_bail_paid", 0) + fine await _commit(user_id, user) _txn("BAIL_PAID", user=user_id, fine=f"-{fine}", pct=f"{pct:.0%}", bal=user["balance"]) return {"ok": True, "fine": fine, "balance": user["balance"]} # --------------------------------------------------------------------------- # /rob # --------------------------------------------------------------------------- async def do_rob(robber_id: int, target_id: int) -> dict: robber = await get_user(robber_id) if robber.get("eco_banned"): return {"ok": False, "reason": "banned"} target = await get_user(target_id) if cd := _cooldown_remaining(robber, "rob"): return {"ok": False, "reason": "cooldown", "remaining": cd} target_jailed = bool(_is_jailed(target)) if jail := _is_jailed(robber): if not target_jailed: return {"ok": False, "reason": "jailed", "remaining": jail} elif target_jailed: return {"ok": False, "reason": "target_jailed"} is_house = (HOUSE_ID is not None and target_id == HOUSE_ID) if is_house and target["balance"] < 50: return {"ok": False, "reason": "broke"} if not is_house and target["balance"] < 100: return {"ok": False, "reason": "broke"} robber["last_rob"] = _now().isoformat() if "anticheat" in target["items"] and not is_house: fine = random.randint(100, 200) robber["balance"] = max(0, robber["balance"] - fine) # Decrement anticheat uses uses = target.get("item_uses", {}).get("anticheat", 2) - 1 if "item_uses" not in target: target["item_uses"] = {} if uses <= 0: target["items"] = [i for i in target["items"] if i != "anticheat"] target["item_uses"].pop("anticheat", None) else: target["item_uses"]["anticheat"] = uses robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine await _commit(robber_id, robber) await _commit(target_id, target) await _credit_house(fine) _txn("ROB_BLOCKED", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"], ac_uses_left=uses) return {"ok": True, "success": False, "reason": "valvur", "fine": fine} # Robbing the house has lower success (35%) but jackpot chance success_chance = 0.35 if is_house else (0.60 if "jellyfin" in robber["items"] else 0.45) if random.random() < success_chance: jackpot = is_house and random.random() < 0.10 if jackpot: pct = 0.40 elif is_house: pct = random.uniform(0.05, 0.15) else: pct = random.uniform(0.10, 0.25) stolen = max(10, min(int(target["balance"] * pct), target["balance"])) target["balance"] -= stolen robber["balance"] += stolen robber["lifetime_earned"] = robber.get("lifetime_earned", 0) + stolen robber["biggest_win"] = max(robber.get("biggest_win", 0), stolen) robber["peak_balance"] = max(robber.get("peak_balance", 0), robber["balance"]) await _commit(robber_id, robber) await _commit(target_id, target) _txn("ROB_WIN", robber=robber_id, victim=target_id, stolen=f"+{stolen}", jackpot=jackpot, robber_bal=robber["balance"], victim_bal=target["balance"]) return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot} else: fine = random.randint(100, 250) robber["balance"] = max(0, robber["balance"] - fine) robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine robber["biggest_loss"] = max(robber.get("biggest_loss", 0), fine) await _commit(robber_id, robber) await _credit_house(fine) _txn("ROB_FAIL", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"]) return {"ok": True, "success": False, "reason": "caught", "fine": fine, "balance": robber["balance"]} # --------------------------------------------------------------------------- # /roulette # --------------------------------------------------------------------------- async def do_roulette(user_id: int, bet: int, colour: str) -> dict: user = await get_user(user_id) if user.get("eco_banned"): return {"ok": False, "reason": "banned"} if jail := _is_jailed(user): return {"ok": False, "reason": "jailed", "remaining": jail} if user["balance"] < bet: return {"ok": False, "reason": "insufficient"} # Wheel: 18 red, 18 black, 1 green (37 slots - real roulette proportions) result = random.choices(["punane", "must", "roheline"], weights=[18, 18, 1])[0] won = result == colour mult = 14 if colour == "roheline" else 1 change = bet * mult if won else -bet user["balance"] = max(0, user["balance"] + change) user["total_wagered"] = user.get("total_wagered", 0) + bet if won: user["lifetime_earned"] = user.get("lifetime_earned", 0) + abs(change) user["biggest_win"] = max(user.get("biggest_win", 0), abs(change)) user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) else: user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet user["biggest_loss"] = max(user.get("biggest_loss", 0), bet) await _commit(user_id, user) if not won: await _credit_house(bet) _txn("ROULETTE_" + ("WIN" if won else "LOSE"), user=user_id, bet=bet, colour=colour, result=result, mult=mult, bal=user["balance"]) return { "ok": True, "won": won, "result": result, "change": abs(change), "mult": mult, "balance": user["balance"], } # --------------------------------------------------------------------------- # /rps (bet resolution) # --------------------------------------------------------------------------- async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict: """Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'.""" user = await get_user(user_id) if user.get("eco_banned"): return {"ok": False, "reason": "banned"} if jail := _is_jailed(user): return {"ok": False, "reason": "jailed", "remaining": jail} if user["balance"] < bet: return {"ok": False, "reason": "insufficient"} user["total_wagered"] = user.get("total_wagered", 0) + bet if outcome == "win": user["balance"] += bet user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet user["biggest_win"] = max(user.get("biggest_win", 0), bet) user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) elif outcome == "lose": user["balance"] = max(0, user["balance"] - bet) user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet user["biggest_loss"] = max(user.get("biggest_loss", 0), bet) # tie: no change await _commit(user_id, user) if outcome == "lose" and bet > 0: await _credit_house(bet) _txn("RPS_" + outcome.upper(), user=user_id, bet=bet, bal=user["balance"]) return {"ok": True, "balance": user["balance"]} # --------------------------------------------------------------------------- # /slots # --------------------------------------------------------------------------- _SLOTS_SYMBOLS: list[tuple[str, int]] = [ ("<:TipiHEART:1483431377561976853>", 27), ("<:TipiFIRE:1483431381668335687>", 22), ("<:TipiTROLL:1483431380166774895>", 18), ("<:TipICRY:1483431288852709387>", 15), ("<:TipiSKULL:1483431378929451028>", 10), ("<:TipiKARIKAS:1483014841148112977>", 8), ] _SLOTS_JACKPOT = "<:TipiKARIKAS:1483014841148112977>" _SLOTS_TRIPLE_MULT: dict[str, int] = { "<:TipiHEART:1483431377561976853>": 4, "<:TipiFIRE:1483431381668335687>": 5, "<:TipiTROLL:1483431380166774895>": 7, "<:TipICRY:1483431288852709387>": 10, "<:TipiSKULL:1483431378929451028>": 15, "<:TipiKARIKAS:1483014841148112977>": 25, # jackpot } def _spin() -> str: symbols, weights = zip(*_SLOTS_SYMBOLS) return random.choices(list(symbols), weights=list(weights), k=1)[0] async def do_slots(user_id: int, bet: int) -> dict: user = await get_user(user_id) if user.get("eco_banned"): return {"ok": False, "reason": "banned"} if jail := _is_jailed(user): return {"ok": False, "reason": "jailed", "remaining": jail} if user["balance"] < bet: return {"ok": False, "reason": "insufficient"} reels = [_spin(), _spin(), _spin()] a, b, c = reels has_360 = "monitor_360" in user["items"] if a == b == c: tier = "jackpot" if a == _SLOTS_JACKPOT else "triple" base_mult = _SLOTS_TRIPLE_MULT.get(a, 4) mult = int(base_mult * 1.5) if has_360 else base_mult change = bet * (mult - 1) elif a == b or b == c or a == c: tier = "pair" change = bet // 2 else: tier = "miss" change = -bet user["balance"] = max(0, user["balance"] + change) user["total_wagered"] = user.get("total_wagered", 0) + bet if tier in ("jackpot", "triple", "pair"): user["lifetime_earned"] = user.get("lifetime_earned", 0) + change user["biggest_win"] = max(user.get("biggest_win", 0), change) user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) if tier == "jackpot": user["slots_jackpots"] = user.get("slots_jackpots", 0) + 1 else: user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet user["biggest_loss"] = max(user.get("biggest_loss", 0), bet) await _commit(user_id, user) if tier == "miss": await _credit_house(bet) _txn("SLOTS_" + tier.upper(), user=user_id, bet=bet, change=change, bal=user["balance"]) return { "ok": True, "reels": reels, "tier": tier, "change": change, "balance": user["balance"], } # --------------------------------------------------------------------------- # /give # --------------------------------------------------------------------------- async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: giver = await get_user(giver_id) if giver.get("eco_banned"): return {"ok": False, "reason": "banned"} if rem := _is_jailed(giver): return {"ok": False, "reason": "jailed", "remaining": rem} if giver["balance"] < amount: return {"ok": False, "reason": "insufficient"} receiver = await get_user(receiver_id) giver["balance"] -= amount receiver["balance"] += amount giver["total_given"] = giver.get("total_given", 0) + amount receiver["total_received"] = receiver.get("total_received", 0) + amount await _commit(giver_id, giver) await _commit(receiver_id, receiver) _txn("GIVE", from_=giver_id, to=receiver_id, amount=amount, from_bal=giver["balance"], to_bal=receiver["balance"]) return { "ok": True, "giver_balance": giver["balance"], "receiver_balance": receiver["balance"], } # --------------------------------------------------------------------------- # /buy # --------------------------------------------------------------------------- async def do_buy(user_id: int, item_id: str) -> dict: if item_id not in SHOP: return {"ok": False, "reason": "not_found"} user = await get_user(user_id) if user.get("eco_banned"): return {"ok": False, "reason": "banned"} item = SHOP[item_id] if item_id in user["items"]: # Allow repurchase of anticheat if uses are depleted if item_id == "anticheat" and user.get("item_uses", {}).get("anticheat", 2) <= 0: user["items"] = [i for i in user["items"] if i != "anticheat"] user.get("item_uses", {}).pop("anticheat", None) else: return {"ok": False, "reason": "owned"} min_level = SHOP_LEVEL_REQ.get(item_id, 0) if min_level > 0: user_level = get_level(user.get("exp", 0)) if user_level < min_level: return {"ok": False, "reason": "level_required", "min_level": min_level, "user_level": user_level} if user["balance"] < item["cost"]: return {"ok": False, "reason": "insufficient", "need": item["cost"] - user["balance"]} user["balance"] -= item["cost"] user["items"].append(item_id) if item_id == "anticheat": if "item_uses" not in user: user["item_uses"] = {} user["item_uses"]["anticheat"] = 2 await _commit(user_id, user) _txn("BUY", user=user_id, item=item_id, cost=f"-{item['cost']}", bal=user["balance"]) return {"ok": True, "item": item, "balance": user["balance"]} # --------------------------------------------------------------------------- # Admin actions # --------------------------------------------------------------------------- async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str) -> dict: """Give (positive) or take (negative) coins from a user. Balance is floored at 0.""" user = await get_user(target_id) user["balance"] = max(0, user["balance"] + amount) await _commit(target_id, user) verb = f"+{amount}" if amount >= 0 else str(amount) _txn("ADMIN_COINS", admin=admin_id, target=target_id, amount=verb, reason=reason, bal=user["balance"]) return {"ok": True, "balance": user["balance"], "change": amount} async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str) -> dict: """Manually jail a user for `minutes` minutes.""" user = await get_user(target_id) user["jailed_until"] = (_now() + timedelta(minutes=minutes)).isoformat() user["jailbreak_used"] = False await _commit(target_id, user) _txn("ADMIN_JAIL", admin=admin_id, target=target_id, minutes=minutes, reason=reason) return {"ok": True, "jailed_until": user["jailed_until"]} async def do_admin_unjail(target_id: int, admin_id: int) -> dict: """Remove jail from a user.""" user = await get_user(target_id) user["jailed_until"] = None user["jailbreak_used"] = False await _commit(target_id, user) _txn("ADMIN_UNJAIL", admin=admin_id, target=target_id) return {"ok": True} async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict: """Ban a user from all economy commands.""" user = await get_user(target_id) user["eco_banned"] = True await _commit(target_id, user) _txn("ADMIN_BAN", admin=admin_id, target=target_id, reason=reason) return {"ok": True} async def do_admin_unban(target_id: int, admin_id: int) -> dict: """Lift an economy ban.""" user = await get_user(target_id) user["eco_banned"] = False await _commit(target_id, user) _txn("ADMIN_UNBAN", admin=admin_id, target=target_id) return {"ok": True} async def do_admin_reset(target_id: int, admin_id: int) -> dict: """Wipe a user's economy data back to defaults.""" user = await get_user(target_id) fresh = _default_user() fresh["_pb_id"] = user.get("_pb_id") # type: ignore[typeddict-unknown-key] await _commit(target_id, fresh) _txn("ADMIN_RESET", admin=admin_id, target=target_id) return {"ok": True} async def do_admin_inspect(target_id: int) -> dict: """Return the user's full raw economy data.""" user = await get_user(target_id) return {"ok": True, "data": dict(user)} # --------------------------------------------------------------------------- # /reminders # --------------------------------------------------------------------------- async def do_set_reminders(user_id: int, commands: list[str]) -> None: """Overwrite the user's reminder list with the given command names.""" user = await get_user(user_id) user["reminders"] = list(commands) await _commit(user_id, user) # --------------------------------------------------------------------------- # /blackjack # --------------------------------------------------------------------------- async def do_blackjack_bet(user_id: int, bet: int) -> dict: """Deduct the initial blackjack bet. Returns ok/fail.""" user = await get_user(user_id) if user.get("eco_banned"): return {"ok": False, "reason": "banned"} if jail := _is_jailed(user): return {"ok": False, "reason": "jailed", "remaining": jail} if user["balance"] < bet: return {"ok": False, "reason": "insufficient", "balance": user["balance"]} user["balance"] -= bet await _commit(user_id, user) return {"ok": True, "balance": user["balance"]} async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict: """Credit the net payout. House receives the difference when payout < total_invested.""" user = await get_user(user_id) user["balance"] += payout user["balance"] = max(0, user["balance"]) user["total_wagered"] = user.get("total_wagered", 0) + total_invested net = payout - total_invested if net > 0: user["lifetime_earned"] = user.get("lifetime_earned", 0) + net user["biggest_win"] = max(user.get("biggest_win", 0), net) user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) elif net < 0: user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net) user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net)) await _commit(user_id, user) house_gain = total_invested - payout if house_gain > 0: await _credit_house(house_gain) _txn("BLACKJACK", user=user_id, payout=f"{payout:+}", net=f"{net:+}", bal=user["balance"]) return {"ok": True, "balance": user["balance"]} # --------------------------------------------------------------------------- # /jailed # --------------------------------------------------------------------------- async def do_get_jailed() -> list[tuple[int, timedelta]]: """Return [(user_id, remaining)] for every user currently in jail.""" all_users = await get_all_users_raw() result: list[tuple[int, timedelta]] = [] for uid_str, user in all_users.items(): if rem := _is_jailed(user): result.append((int(uid_str), rem)) result.sort(key=lambda x: x[1]) return result # --------------------------------------------------------------------------- # /heist # --------------------------------------------------------------------------- async def do_heist_check(user_id: int) -> dict: """Check whether a user is eligible to join a heist.""" user = await get_user(user_id) if user.get("eco_banned"): return {"ok": False, "reason": "banned"} if jail := _is_jailed(user): return {"ok": False, "reason": "jailed", "remaining": jail} return {"ok": True} async def do_heist_resolve(user_ids: list[int], success: bool) -> dict: """Apply heist outcome to all participants. On win, steals from house.""" now = _now() payout_each = 0 if success and HOUSE_ID is not None: house = await get_user(HOUSE_ID) pct = random.uniform(0.20, 0.55) total = max(300, int(house["balance"] * pct)) payout_each = total // len(user_ids) house["balance"] = max(0, house["balance"] - total) await _commit(HOUSE_ID, house) _txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"]) for uid in user_ids: user = await get_user(uid) user["last_heist"] = now.isoformat() user["heists_joined"] = user.get("heists_joined", 0) + 1 if success: user["balance"] += payout_each user["heists_won"] = user.get("heists_won", 0) + 1 user["lifetime_earned"] = user.get("lifetime_earned", 0) + payout_each user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) _txn("HEIST_WIN", user=uid, change=f"+{payout_each}", bal=user["balance"]) else: fine = max(150, min(1000, int(user["balance"] * 0.15))) user["balance"] = max(0, user["balance"] - fine) user["jailed_until"] = (now + HEIST_JAIL).isoformat() user["jailbreak_used"] = False user["times_jailed"] = user.get("times_jailed", 0) + 1 user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine _txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"]) if fine > 0: await _credit_house(fine) await _commit(uid, user) return {"ok": True, "payout_each": payout_each, "success": success}