"""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 asyncio import functools import logging import math import random from datetime import date, datetime, timedelta, timezone from typing import TypedDict import aiohttp from . import pb_client from .pb_client import DatabaseError from .emoji import EMOJI as E 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) # Per-profile emoji values live in core/emoji.py; add new IDs there. COIN = E["TipiCOIN"] PP_EMOJI = E["TipiFIRE"] PRESTIGE_ROLE = "TipiPRESTIGE" PRESTIGE_MIN_LEVEL = 30 # minimum level required to prestige # --------------------------------------------------------------------------- # Shop catalogue # --------------------------------------------------------------------------- class ShopItem(TypedDict): name: str emoji: str cost: int description: str SHOP: dict[str, ShopItem] = { "gaming_hiir": { "name": "MΓ€ngurihiir", "emoji": E["TipiHIIR"], "cost": 500, "description": strings.ITEM_DESCRIPTIONS["gaming_hiir"], }, "hiirematt": { "name": "Hiirematt", "emoji": E["TipiMATT"], "cost": 600, "description": strings.ITEM_DESCRIPTIONS["hiirematt"], }, "korvaklapid": { "name": "K\u00f5rvaklapid", "emoji": E["TipiKLAPID"], "cost": 1200, "description": strings.ITEM_DESCRIPTIONS["korvaklapid"], }, "lan_pass": { "name": "LAN pilet", "emoji": E["TipiPILET"], "cost": 1200, "description": strings.ITEM_DESCRIPTIONS["lan_pass"], }, "energiajook": { "name": "Red Bull", "emoji": E["TipiBULL"], "cost": 800, "description": strings.ITEM_DESCRIPTIONS["energiajook"], }, "gaming_laptop": { "name": "Bot Farm", "emoji": E["TipiLAP"], "cost": 1500, "description": strings.ITEM_DESCRIPTIONS["gaming_laptop"], }, "anticheat": { "name": "Anticheat", "emoji": E["TipiVAC"], "cost": 1000, "description": strings.ITEM_DESCRIPTIONS["anticheat"], }, # ----- Tier 2 ----- "reguleeritav_laud": { "name": "Reguleeritav laud", "emoji": E["TipiLAUD"], "cost": 3500, "description": strings.ITEM_DESCRIPTIONS["reguleeritav_laud"], }, "jellyfin": { "name": "Jellyfin server", "emoji": E["TipiSERVER"], "cost": 4000, "description": strings.ITEM_DESCRIPTIONS["jellyfin"], }, "mikrofon": { "name": "Eraldiseisev mikrofon", "emoji": E["TipiMIC"], "cost": 2800, "description": strings.ITEM_DESCRIPTIONS["mikrofon"], }, "klaviatuur": { "name": "Mehaaniline klaviatuur", "emoji": E["TipiKLAVA"], "cost": 1800, "description": strings.ITEM_DESCRIPTIONS["klaviatuur"], }, "monitor": { "name": "Ultralai monitor", "emoji": E["TipiMONITOR"], "cost": 2500, "description": strings.ITEM_DESCRIPTIONS["monitor"], }, "cat6": { "name": "Cat6 kaabel", "emoji": E["TipiCAT"], "cost": 3500, "description": strings.ITEM_DESCRIPTIONS["cat6"], }, # ----- Tier 3 ----- "monitor_360": { "name": "360Hz monitor", "emoji": E["TipiMONITOR2"], "cost": 7500, "description": strings.ITEM_DESCRIPTIONS["monitor_360"], }, "karikas": { "name": "TipiLAN karikas", "emoji": E["TipiKARIKAS"], "cost": 6000, "description": strings.ITEM_DESCRIPTIONS["karikas"], }, "gaming_tool": { "name": "Gaming tool", "emoji": E["TipiTOOL"], "cost": 9000, "description": strings.ITEM_DESCRIPTIONS["gaming_tool"], }, # ----- Fishing items ----- "ussipurk": { "name": "Ussipurk", "emoji": "πŸͺ£", "cost": 3500, "description": strings.ITEM_DESCRIPTIONS["ussipurk"], }, "kalavork": { "name": "KalavΓ΅rk", "emoji": "πŸͺ", "cost": 5000, "description": strings.ITEM_DESCRIPTIONS["kalavork"], }, "echolood": { "name": "Echolood", "emoji": "πŸ“‘", "cost": 8000, "description": strings.ITEM_DESCRIPTIONS["echolood"], }, } # 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", "ussipurk"], 3: ["monitor_360", "karikas", "gaming_tool", "kalavork", "echolood"], } # 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, "ussipurk": 10, "monitor_360": 20, "karikas": 20, "gaming_tool": 20, "kalavork": 20, "echolood": 20, } # --------------------------------------------------------------------------- # Prestige shop catalogue # --------------------------------------------------------------------------- class PrestigeItem(TypedDict): emoji: str max_level: int pp_cost: int effect: float PRESTIGE_SHOP: dict[str, PrestigeItem] = { "coin_mult": { "emoji": E["TipiCOIN"], "max_level": 5, "pp_cost": 5, "effect": 0.08, }, "exp_mult": { "emoji": "✨", "max_level": 5, "pp_cost": 5, "effect": 0.08, }, "daily_plus": { "emoji": "πŸ“…", "max_level": 3, "pp_cost": 7, "effect": 0.20, }, "work_plus": { "emoji": "πŸ’Ό", "max_level": 3, "pp_cost": 7, "effect": 0.20, }, } # --------------------------------------------------------------------------- # Fish catalogue # --------------------------------------------------------------------------- FISH_CATALOGUE: dict[str, dict] = { # id: { rarity, weight=(min_g, max_g), coins=(min, max), exp } "sarj": {"rarity": "common", "weight": (50, 500), "coins": (3, 18), "exp": 3}, "ahven": {"rarity": "common", "weight": (80, 700), "coins": (5, 22), "exp": 3}, "koger": {"rarity": "common", "weight": (100, 800), "coins": (5, 20), "exp": 3}, "viidikas": {"rarity": "common", "weight": (10, 120), "coins": (2, 8), "exp": 2}, "latikas": {"rarity": "uncommon", "weight": (300, 2500), "coins": (20, 70), "exp": 6}, "karpkala": {"rarity": "uncommon", "weight": (500, 4000), "coins": (25, 80), "exp": 7}, "linask": {"rarity": "uncommon", "weight": (200, 2000), "coins": (18, 60), "exp": 6}, "haug": {"rarity": "rare", "weight": (500, 6000), "coins": (50, 180), "exp": 10}, "angerjas": {"rarity": "rare", "weight": (200, 1800), "coins": (40, 120), "exp": 10}, "siig": {"rarity": "rare", "weight": (200, 2000), "coins": (45, 130), "exp": 10}, "forell": {"rarity": "epic", "weight": (400, 4500), "coins": (100, 280), "exp": 15}, "koha": {"rarity": "epic", "weight": (600, 7000), "coins": (120, 300), "exp": 15}, "tougjas": {"rarity": "epic", "weight": (400, 4000), "coins": (90, 250), "exp": 14}, "lohe": {"rarity": "legendary","weight": (1500, 12000), "coins": (250, 700), "exp": 25}, "vimb": {"rarity": "legendary","weight": (200, 1200), "coins": (200, 600), "exp": 25}, } FISH_RARITY_WEIGHTS: dict[str, int] = { "junk": 15, "common": 45, "uncommon": 22, "rare": 12, "epic": 5, "legendary": 1, } def roll_fish(rarity_bump: bool = False) -> tuple[str, int]: """Roll a random fish. Returns (fish_id, weight_grams) or ('junk', 0). rarity_bump=True (kalavork item) shifts each catch one tier up. """ rarity_pool = list(FISH_RARITY_WEIGHTS.keys()) weights = list(FISH_RARITY_WEIGHTS.values()) chosen_rarity = random.choices(rarity_pool, weights=weights)[0] if chosen_rarity == "junk": return ("junk", 0) if rarity_bump: order = ["common", "uncommon", "rare", "epic", "legendary"] idx = order.index(chosen_rarity) if chosen_rarity in order else 0 chosen_rarity = order[min(idx + 1, len(order) - 1)] fish_of_rarity = [k for k, v in FISH_CATALOGUE.items() if v["rarity"] == chosen_rarity] if not fish_of_rarity: return ("junk", 0) fish_id = random.choice(fish_of_rarity) fish = FISH_CATALOGUE[fish_id] weight = random.randint(fish["weight"][0], fish["weight"][1]) return (fish_id, weight) # --------------------------------------------------------------------------- # 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. Recurrence: exp_for_level(L) = L*20 - 10 + exp_for_level(L-1), base 0. Closed form: 10*level^2.""" if level <= 1: return 0 return 10 * level * level 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), "fish": timedelta(minutes=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 # Prestige system prestige_level: int prestige_points: int season_total_exp: int # cumulative EXP this season (survives prestige resets) prestige_upgrades: dict # {upgrade_id: level} # Fishing system last_fish: str | None fish_book: dict # {fish_id: times_caught} total_fish_caught: int fish_inventory: list # [{fish_id, weight, value}] - survives prestige # Quest system quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}} quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}} 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, # ── Prestige ───────────────────────────────────────────────────────── "prestige_level": 0, "prestige_points": 0, "season_total_exp": 0, "prestige_upgrades": {}, # ── Fishing ────────────────────────────────────────────────────────── "last_fish": None, "fish_book": {}, "total_fish_caught": 0, "fish_inventory": [], # ── Quests ─────────────────────────────────────────────────────────── "quest_daily": {}, "quest_weekly": {}, } # --------------------------------------------------------------------------- # Persistence (PocketBase backend) # --------------------------------------------------------------------------- _log = logging.getLogger("tipiCOIN.economy") # --------------------------------------------------------------------------- # Per-user write locks # --------------------------------------------------------------------------- # Every mutation is a read-modify-write cycle (get_user β†’ mutate β†’ _commit); # without serialization, two concurrent commands for the same user overwrite # each other's commit. Locking rules that keep this deadlock-free: # - a decorated function must never call another decorated function # - house balance changes go through _credit_house (an atomic PocketBase # increment, no lock), so they are safe while holding user locks _user_locks: dict[int, asyncio.Lock] = {} def _user_lock(user_id: int) -> asyncio.Lock: lock = _user_locks.get(user_id) if lock is None: lock = _user_locks[user_id] = asyncio.Lock() return lock def _locked_by(*arg_positions: int): """Serialize the decorated function per user id found at the given positional-argument indices. Multiple ids are acquired in sorted order so two-user functions (do_give, do_rob) cannot deadlock each other.""" def decorator(fn): @functools.wraps(fn) async def wrapper(*args, **kwargs): locks = [_user_lock(uid) for uid in sorted({args[pos] for pos in arg_positions})] for lock in locks: await lock.acquire() try: return await fn(*args, **kwargs) finally: for lock in reversed(locks): lock.release() return wrapper return decorator # --------------------------------------------------------------------------- # House account (bot user) # --------------------------------------------------------------------------- HOUSE_ID: int | None = None _house_pb_id: str | None = None def set_house(user_id: int) -> None: """Register the bot's Discord user ID as the house account.""" global HOUSE_ID, _house_pb_id if HOUSE_ID != user_id: _house_pb_id = None HOUSE_ID = user_id async def _house_record_id() -> str | None: """PocketBase record id of the house account (cached; creates the record on first use).""" global _house_pb_id if HOUSE_ID is None: return None if _house_pb_id is None: house = await get_user(HOUSE_ID) _house_pb_id = house.get("_pb_id") # type: ignore[typeddict-item] return _house_pb_id async def _credit_house(amount: int) -> None: """Add `amount` coins to the house via an atomic PocketBase increment. Deliberately lock-free: callers hold per-user locks, so this must never acquire one itself (see the locking rules above).""" if amount <= 0: return record_id = await _house_record_id() if record_id is None: return await pb_client.update_record(record_id, {"balance+": amount}) 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.""" record_id = await _house_record_id() if record_id is None: return await pb_client.update_record(record_id, {"heist_global_cd_until": until}) @_locked_by(0) 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 missing_schema_fields() -> list[str]: """Compare the live PocketBase collection schema against every field the bot persists. PocketBase silently drops writes to undeclared fields, so any name returned here means broken features without error messages.""" live = await pb_client.get_collection_fields() expected = set(_default_user()) | {"user_id"} return sorted(expected - live) 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 # --------------------------------------------------------------------------- # 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) try: 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) except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc: _log.error("PocketBase unreachable for user %s: %s", user_id, exc) raise DatabaseError(f"Database unavailable: {exc}") from exc 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] def _prestige_mult(user: UserData) -> tuple[float, float]: """Return (coin_mult, exp_mult) based on prestige upgrades. Both β‰₯1.0.""" upgrades: dict = user.get("prestige_upgrades") or {} # type: ignore[assignment] coin_level = upgrades.get("coin_mult", 0) exp_level = upgrades.get("exp_mult", 0) return ( 1.0 + coin_level * PRESTIGE_SHOP["coin_mult"]["effect"], 1.0 + exp_level * PRESTIGE_SHOP["exp_mult"]["effect"], ) 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] @_locked_by(0) async def award_exp(user_id: int, amount: int) -> dict: """Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp.""" user = await get_user(user_id) _, exp_mult = _prestige_mult(user) gained = max(1, int(amount * exp_mult)) old_exp = user.get("exp", 0) new_exp = old_exp + gained old_level = get_level(old_exp) new_level = get_level(new_exp) user["exp"] = new_exp user["season_total_exp"] = user.get("season_total_exp", 0) + gained await _commit(user_id, user) return {"old_level": old_level, "new_level": new_level, "exp": new_exp, "gained": gained} 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, "last_fish": None, "daily_streak": 0, "last_streak_date": None, "season_total_exp": 0, } 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) -> dict | None: """Persist the full user record. Returns the record as PocketBase stored it (fields absent from the collection schema are silently dropped by PB).""" 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) try: if record_id: return 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] return created except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc: _log.error("_commit failed for user %s: %s", user_id, exc) raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc # --------------------------------------------------------------------------- # Quest system # --------------------------------------------------------------------------- # Quests reuse the monotonic lifetime counters already tracked on each user. # Progress = (current counter value) - (snapshot taken when the quest was rolled). # Reset is lazy & per-user: the active set is regenerated the first time a user # interacts after the day / ISO-week rolls over (mirrors the streak-date logic). # Rotation is seeded by user id + period key, so each player gets their own set. class QuestDef(TypedDict): stat: str # UserData counter field the quest tracks goal: int coins: int exp: int QUESTS_DAILY: dict[str, QuestDef] = { "work3": {"stat": "work_count", "goal": 3, "coins": 150, "exp": 20}, "beg5": {"stat": "beg_count", "goal": 5, "coins": 100, "exp": 15}, "wager500": {"stat": "total_wagered", "goal": 500, "coins": 150, "exp": 20}, "fish2": {"stat": "total_fish_caught", "goal": 2, "coins": 150, "exp": 20}, "crime1": {"stat": "crimes_succeeded", "goal": 1, "coins": 200, "exp": 25}, "earn1000": {"stat": "lifetime_earned", "goal": 1000, "coins": 150, "exp": 20}, "give200": {"stat": "total_given", "goal": 200, "coins": 100, "exp": 15}, } QUESTS_WEEKLY: dict[str, QuestDef] = { "work20": {"stat": "work_count", "goal": 20, "coins": 1000, "exp": 100}, "fish15": {"stat": "total_fish_caught", "goal": 15, "coins": 1200, "exp": 100}, "wager5000": {"stat": "total_wagered", "goal": 5000, "coins": 1000, "exp": 100}, "crime5": {"stat": "crimes_succeeded", "goal": 5, "coins": 1200, "exp": 120}, "heist1": {"stat": "heists_joined", "goal": 1, "coins": 800, "exp": 80}, "earn10000": {"stat": "lifetime_earned", "goal": 10000, "coins": 1500, "exp": 150}, } DAILY_QUEST_COUNT = 3 WEEKLY_QUEST_COUNT = 2 def _period_keys() -> tuple[str, str]: """Return (day_key, week_key) for the current UTC time.""" today = _now().date() iso = today.isocalendar() return today.isoformat(), f"{iso[0]}-W{iso[1]:02d}" def _pick_quests(pool: dict[str, QuestDef], count: int, seed: str) -> list[str]: """Deterministically choose `count` quest ids from `pool` for a period.""" rng = random.Random(seed) return rng.sample(sorted(pool.keys()), min(count, len(pool))) def _new_quest_block( user: UserData, user_id: int, pool: dict[str, QuestDef], count: int, period_val: str, period_field: str ) -> dict: chosen = _pick_quests(pool, count, f"{user_id}:{period_field}:{period_val}") return { period_field: period_val, "quests": { qid: {"snap": int(user.get(pool[qid]["stat"], 0) or 0), "claimed": False} for qid in chosen }, } def _ensure_quests(user: UserData, user_id: int) -> bool: """Roll fresh daily/weekly quest sets if their period elapsed. Mutates `user` in place; returns True if anything changed (caller commits).""" changed = False day_key, week_key = _period_keys() if (user.get("quest_daily") or {}).get("date") != day_key: user["quest_daily"] = _new_quest_block(user, user_id, QUESTS_DAILY, DAILY_QUEST_COUNT, day_key, "date") changed = True if (user.get("quest_weekly") or {}).get("week") != week_key: user["quest_weekly"] = _new_quest_block(user, user_id, QUESTS_WEEKLY, WEEKLY_QUEST_COUNT, week_key, "week") changed = True return changed def _quest_progress(user: UserData, pool: dict[str, QuestDef], qid: str, state: dict) -> int: cur = int(user.get(pool[qid]["stat"], 0) or 0) return max(0, cur - int(state.get("snap", 0))) def _quest_view(user: UserData) -> dict: def build(pool: dict[str, QuestDef], block: dict) -> list[dict]: out: list[dict] = [] for qid, state in (block.get("quests") or {}).items(): if qid not in pool: continue d = pool[qid] prog = min(d["goal"], _quest_progress(user, pool, qid, state)) out.append({ "id": qid, "goal": d["goal"], "coins": d["coins"], "exp": d["exp"], "progress": prog, "done": prog >= d["goal"], "claimed": bool(state.get("claimed")), }) return out return { "daily": build(QUESTS_DAILY, user.get("quest_daily") or {}), "weekly": build(QUESTS_WEEKLY, user.get("quest_weekly") or {}), } @_locked_by(0) async def get_quests(user_id: int) -> dict: """Return the user's active quests, rolling new sets if the period elapsed.""" user = await get_user(user_id) if _ensure_quests(user, user_id): saved = await _commit(user_id, user) if saved is not None and "quest_daily" not in saved: _log.warning( "PocketBase collection has no quest fields - quest state is not " "persisted and progress will stay at 0. Run scripts/add_quest_fields.py." ) return _quest_view(user) @_locked_by(0) async def claim_quests(user_id: int) -> dict: """Grant coins for every completed-but-unclaimed quest and mark them claimed. Coins (with prestige coin_mult) are paid here; EXP is returned raw for the caller to award via the shared award_exp path (keeps level-up notices).""" user = await get_user(user_id) _ensure_quests(user, user_id) coin_mult, _ = _prestige_mult(user) total_coins = total_exp = claimed = 0 for pool, block in ( (QUESTS_DAILY, user.get("quest_daily") or {}), (QUESTS_WEEKLY, user.get("quest_weekly") or {}), ): for qid, state in (block.get("quests") or {}).items(): if qid not in pool or state.get("claimed"): continue if _quest_progress(user, pool, qid, state) < pool[qid]["goal"]: continue total_coins += pool[qid]["coins"] total_exp += pool[qid]["exp"] state["claimed"] = True claimed += 1 if not claimed: return {"ok": False, "reason": "nothing"} coins_awarded = int(total_coins * coin_mult) user["balance"] += coins_awarded user["lifetime_earned"] = user.get("lifetime_earned", 0) + coins_awarded user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) await _commit(user_id, user) return {"ok": True, "claimed": claimed, "coins": coins_awarded, "exp": total_exp, "balance": user["balance"]} # --------------------------------------------------------------------------- # /daily # --------------------------------------------------------------------------- @_locked_by(0) async def do_daily(user_id: int) -> dict: try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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 daily_plus_level = (user.get("prestige_upgrades") or {}).get("daily_plus", 0) base = int(150 * (1.0 + daily_plus_level * PRESTIGE_SHOP["daily_plus"]["effect"])) earned = int(base * streak_mult * vip_mult) if "korvaklapid" in user["items"]: earned += 25 coin_mult, _ = _prestige_mult(user) earned = int(earned * coin_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 @_locked_by(0) async def do_work(user_id: int) -> dict: try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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 work_plus_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0) work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"] coin_mult, _ = _prestige_mult(user) earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult) 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 @_locked_by(0) async def do_beg(user_id: int) -> dict: try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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 coin_mult, _ = _prestige_mult(user) earned = int(random.randint(10, 40) * beg_mult * coin_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"], } # --------------------------------------------------------------------------- # /fish # --------------------------------------------------------------------------- @_locked_by(0) async def do_fish_start(user_id: int) -> dict: """Check cooldown + jail, set cooldown. Call before starting the fishing minigame.""" try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} if user.get("eco_banned"): return {"ok": False, "reason": "banned"} if jail := _is_jailed(user): return {"ok": False, "reason": "jailed", "remaining": jail} fish_cd = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"] if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd): return {"ok": False, "reason": "cooldown", "remaining": cd} user["last_fish"] = _now().isoformat() await _commit(user_id, user) return {"ok": True} @_locked_by(0) async def do_fish_resolve(user_id: int, fish_id: str, weight: int) -> dict: """Add catch to inventory + update fish_book. Returns catch info incl. pre-calculated value.""" user = await get_user(user_id) if fish_id == "junk": _txn("FISH_JUNK", user=user_id) return {"ok": True, "type": "junk", "coins": 0, "exp": 0} if fish_id not in FISH_CATALOGUE: return {"ok": False, "reason": "invalid_fish"} fish = FISH_CATALOGUE[fish_id] min_c, max_c = fish["coins"] w_min, w_max = fish["weight"] weight_ratio = (weight - w_min) / max(1, w_max - w_min) base_coins = int(min_c + weight_ratio * (max_c - min_c)) coin_mult, _ = _prestige_mult(user) value = int(base_coins * coin_mult) exp = fish["exp"] book: dict = user.get("fish_book") or {} prev_count = book.get(fish_id, 0) book[fish_id] = prev_count + 1 user["fish_book"] = book user["total_fish_caught"] = user.get("total_fish_caught", 0) + 1 inv: list = list(user.get("fish_inventory") or []) inv.append({"fish_id": fish_id, "weight": weight, "value": value}) user["fish_inventory"] = inv await _commit(user_id, user) _txn("FISH", user=user_id, fish=fish_id, weight=weight, value=value) return { "ok": True, "type": "fish", "fish_id": fish_id, "weight": weight, "value": value, "exp": exp, "is_new": prev_count == 0, "total_caught": book[fish_id], } @_locked_by(0) async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict: """Sell fish from inventory. indices=None sells all. Returns coins earned.""" user = await get_user(user_id) inv: list = list(user.get("fish_inventory") or []) if not inv: return {"ok": False, "reason": "empty"} if indices is None: to_sell = inv remaining = [] else: sell_idx = { (i if i >= 0 else len(inv) + i) for i in indices } sell_idx = {i for i in sell_idx if 0 <= i < len(inv)} to_sell = [inv[i] for i in sorted(sell_idx)] keep_idx = set(range(len(inv))) - sell_idx remaining = [inv[i] for i in sorted(keep_idx)] if not to_sell: return {"ok": False, "reason": "empty"} total_coins = sum(entry["value"] for entry in to_sell) user["fish_inventory"] = remaining user["balance"] = user.get("balance", 0) + total_coins user["lifetime_earned"] = user.get("lifetime_earned", 0) + total_coins user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) await _commit(user_id, user) _txn("FISH_SELL", user=user_id, count=len(to_sell), coins=f"+{total_coins}", bal=user["balance"]) return { "ok": True, "coins": total_coins, "count": len(to_sell), "balance": user["balance"], } async def do_fishbook(user_id: int) -> dict: """Return the user's fish book data including per-species inventory counts.""" user = await get_user(user_id) book: dict = user.get("fish_book") or {} inv: list = user.get("fish_inventory") or [] inv_counts: dict[str, int] = {} for entry in inv: fid = entry.get("fish_id", "") inv_counts[fid] = inv_counts.get(fid, 0) + 1 return { "ok": True, "book": book, "inv_counts": inv_counts, "total_fish_caught": user.get("total_fish_caught", 0), "unique_caught": len(book), "total_species": len(FISH_CATALOGUE), } # --------------------------------------------------------------------------- # /prestige # --------------------------------------------------------------------------- @_locked_by(0) async def do_prestige(user_id: int) -> dict: """Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns.""" try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} if user.get("eco_banned"): return {"ok": False, "reason": "banned"} exp = user.get("exp", 0) level = get_level(exp) if level < PRESTIGE_MIN_LEVEL: return {"ok": False, "reason": "level_too_low", "level": level, "required": PRESTIGE_MIN_LEVEL} pp_earned = max(1, exp // 1000) new_prestige_level = user.get("prestige_level", 0) + 1 # Preserve: fish_book, fish_inventory, lifetime stats, prestige_points, season_total_exp, prestige_upgrades user["balance"] = 0 user["exp"] = 0 user["items"] = [] user["item_uses"] = {} user["last_daily"] = None user["last_work"] = None user["last_beg"] = None user["last_crime"] = None user["last_rob"] = None user["last_fish"] = None user["last_heist"] = None user["daily_streak"] = 0 user["last_streak_date"] = None user["jailed_until"] = None user["jailbreak_used"] = False user["prestige_level"] = new_prestige_level user["prestige_points"] = user.get("prestige_points", 0) + pp_earned await _commit(user_id, user) _txn("PRESTIGE", user=user_id, pp_earned=pp_earned, prestige=new_prestige_level, old_exp=exp) return { "ok": True, "pp_earned": pp_earned, "prestige_level": new_prestige_level, "prestige_points": user["prestige_points"], "old_exp": exp, } @_locked_by(0) async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict: """Spend PP to buy a prestige upgrade level.""" if upgrade_id not in PRESTIGE_SHOP: return {"ok": False, "reason": "not_found"} try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} if user.get("eco_banned"): return {"ok": False, "reason": "banned"} upgrade = PRESTIGE_SHOP[upgrade_id] upgrades: dict = user.get("prestige_upgrades") or {} current_level = upgrades.get(upgrade_id, 0) if current_level >= upgrade["max_level"]: return {"ok": False, "reason": "maxed", "max": upgrade["max_level"]} pp = user.get("prestige_points", 0) cost = upgrade["pp_cost"] if pp < cost: return {"ok": False, "reason": "insufficient_pp", "have": pp, "need": cost} upgrades[upgrade_id] = current_level + 1 user["prestige_upgrades"] = upgrades user["prestige_points"] = pp - cost await _commit(user_id, user) _txn("PRESTIGE_BUY", user=user_id, upgrade=upgrade_id, new_level=upgrades[upgrade_id], pp_left=user["prestige_points"]) return { "ok": True, "upgrade_id": upgrade_id, "new_level": upgrades[upgrade_id], "max_level": upgrade["max_level"], "pp_remaining": user["prestige_points"], } # --------------------------------------------------------------------------- # Extended leaderboards # --------------------------------------------------------------------------- async def get_leaderboard_season_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]: """Return (user_id, season_total_exp, prestige_level) sorted by season EXP.""" records = await pb_client.list_all_records() result = sorted( ( (r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 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_prestige(top_n: int | None = 10) -> list[tuple[str, int, int]]: """Return (user_id, prestige_level, prestige_points) sorted by prestige_level then PP.""" records = await pb_client.list_all_records() result = sorted( ( (r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0)) for r in records if r.get("user_id") ), key=lambda x: (x[1], x[2]), reverse=True, ) return result if top_n is None else result[:top_n] async def get_leaderboard_wagered(top_n: int | None = 10) -> list[tuple[str, int]]: """Return (user_id, total_wagered) sorted descending.""" records = await pb_client.list_all_records() result = sorted( ((r["user_id"], r.get("total_wagered", 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_fish(top_n: int | None = 10) -> list[tuple[str, int]]: """Return (user_id, total_fish_caught) sorted descending.""" records = await pb_client.list_all_records() result = sorted( ((r["user_id"], r.get("total_fish_caught", 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] # --------------------------------------------------------------------------- # /crime # --------------------------------------------------------------------------- _CRIME_WIN = strings.CRIME_WIN _CRIME_LOSE = strings.CRIME_LOSE @_locked_by(0) async def do_crime(user_id: int) -> dict: try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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) # --------------------------------------------------------------------------- @_locked_by(0) 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) @_locked_by(0) 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 @_locked_by(0) 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 # --------------------------------------------------------------------------- @_locked_by(0, 1) async def do_rob(robber_id: int, target_id: int) -> dict: try: robber = await get_user(robber_id) except DatabaseError: return {"ok": False, "reason": "db_error"} if robber.get("eco_banned"): return {"ok": False, "reason": "banned"} try: target = await get_user(target_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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 prev_lifetime_earned = robber.get("lifetime_earned", 0) prev_biggest_win = robber.get("biggest_win", 0) prev_peak_balance = robber.get("peak_balance", 0) robber["balance"] += stolen robber["lifetime_earned"] = prev_lifetime_earned + stolen robber["biggest_win"] = max(prev_biggest_win, stolen) robber["peak_balance"] = max(prev_peak_balance, robber["balance"]) try: await _commit(robber_id, robber) except DatabaseError: return {"ok": False, "reason": "db_error"} try: await _commit(target_id, target) except DatabaseError: robber["balance"] -= stolen robber["lifetime_earned"] = prev_lifetime_earned robber["biggest_win"] = prev_biggest_win robber["peak_balance"] = prev_peak_balance try: await _commit(robber_id, robber) except DatabaseError as exc2: _log.critical( "do_rob rollback failed for robber %s after target commit failed: %s", robber_id, exc2, ) return {"ok": False, "reason": "db_error"} _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 # --------------------------------------------------------------------------- @_locked_by(0) async def do_roulette(user_id: int, bet: int, colour: str) -> dict: try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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) # --------------------------------------------------------------------------- @_locked_by(0) async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict: """Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'.""" try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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"]} # --------------------------------------------------------------------------- # /rps PvP escrow (deposit/payout/refund) # --------------------------------------------------------------------------- @_locked_by(0) async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict: """Hold `bet` coins from a player as escrow for a PvP RPS duel.""" try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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["balance"] -= bet user["total_wagered"] = user.get("total_wagered", 0) + bet try: await _commit(user_id, user) except DatabaseError: return {"ok": False, "reason": "db_error"} _txn("RPS_PVP_DEPOSIT", user=user_id, bet=bet, bal=user["balance"]) return {"ok": True, "balance": user["balance"]} @_locked_by(0) async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict: """Credit the duel winner with 2*bet (their stake back + opponent's).""" try: user = await get_user(winner_id) except DatabaseError: return {"ok": False, "reason": "db_error"} payout = bet * 2 user["balance"] = user.get("balance", 0) + payout 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"]) try: await _commit(winner_id, user) except DatabaseError: return {"ok": False, "reason": "db_error"} _txn("RPS_PVP_PAYOUT", user=winner_id, payout=f"+{payout}", bal=user["balance"]) return {"ok": True, "balance": user["balance"]} @_locked_by(0) async def do_rps_pvp_refund(user_id: int, bet: int) -> dict: """Refund a previously escrowed bet (tie / timeout / cancel).""" try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} user["balance"] = user.get("balance", 0) + bet user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet) try: await _commit(user_id, user) except DatabaseError: return {"ok": False, "reason": "db_error"} _txn("RPS_PVP_REFUND", user=user_id, bet=bet, bal=user["balance"]) return {"ok": True, "balance": user["balance"]} # --------------------------------------------------------------------------- # /slots # --------------------------------------------------------------------------- _SLOTS_SYMBOLS: list[tuple[str, int]] = [ (E["TipiHEART"], 27), (E["TipiFIRE"], 22), (E["TipiTROLL"], 18), (E["TipICRY"], 15), (E["TipiSKULL"], 10), (E["TipiKARIKAS"], 8), ] _SLOTS_JACKPOT = E["TipiKARIKAS"] _SLOTS_TRIPLE_MULT: dict[str, int] = { E["TipiHEART"]: 4, E["TipiFIRE"]: 5, E["TipiTROLL"]: 7, E["TipICRY"]: 10, E["TipiSKULL"]: 15, E["TipiKARIKAS"]: 25, # jackpot } def _spin() -> str: symbols, weights = zip(*_SLOTS_SYMBOLS) return random.choices(list(symbols), weights=list(weights), k=1)[0] @_locked_by(0) async def do_slots(user_id: int, bet: int) -> dict: try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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 # --------------------------------------------------------------------------- @_locked_by(0, 1) async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: try: giver = await get_user(giver_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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"} try: receiver = await get_user(receiver_id) except DatabaseError: return {"ok": False, "reason": "db_error"} giver["balance"] -= amount receiver["balance"] += amount giver["total_given"] = giver.get("total_given", 0) + amount receiver["total_received"] = receiver.get("total_received", 0) + amount try: await _commit(giver_id, giver) except DatabaseError: return {"ok": False, "reason": "db_error"} try: await _commit(receiver_id, receiver) except DatabaseError: giver["balance"] += amount giver["total_given"] = max(0, giver.get("total_given", 0) - amount) try: await _commit(giver_id, giver) except DatabaseError as exc2: _log.critical( "do_give rollback failed for giver %s after receiver commit failed: %s", giver_id, exc2, ) return {"ok": False, "reason": "db_error"} _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 # --------------------------------------------------------------------------- @_locked_by(0) async def do_buy(user_id: int, item_id: str) -> dict: if item_id not in SHOP: return {"ok": False, "reason": "not_found"} try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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 # --------------------------------------------------------------------------- @_locked_by(0) 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} @_locked_by(0) 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"]} @_locked_by(0) 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} @_locked_by(0) 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} @_locked_by(0) 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} @_locked_by(0) 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)} @_locked_by(0) async def do_admin_exp(target_id: int, amount: int, admin_id: int, reason: str) -> dict: """Give (positive) or take (negative) EXP from a user. EXP is floored at 0.""" user = await get_user(target_id) old_exp = user.get("exp", 0) old_level = get_level(old_exp) user["exp"] = max(0, old_exp + amount) user["season_total_exp"] = max(0, user.get("season_total_exp", 0) + amount) new_level = get_level(user["exp"]) await _commit(target_id, user) verb = f"+{amount}" if amount >= 0 else str(amount) _txn("ADMIN_EXP", admin=admin_id, target=target_id, amount=verb, reason=reason, exp=user["exp"]) return { "ok": True, "exp": user["exp"], "change": amount, "old_level": old_level, "new_level": new_level, "level_changed": new_level != old_level, } @_locked_by(0) async def do_admin_item(target_id: int, item_id: str, action: str, admin_id: int) -> dict: """Give or remove an item. action='give'|'remove'. Returns ok/reason.""" if item_id not in SHOP: return {"ok": False, "reason": "invalid_item"} user = await get_user(target_id) items: list = list(user.get("items") or []) item_uses: dict = dict(user.get("item_uses") or {}) if action == "give": if item_id not in items: items.append(item_id) if item_id == "anticheat": item_uses["anticheat"] = 2 user["items"] = items user["item_uses"] = item_uses await _commit(target_id, user) _txn("ADMIN_ITEM_GIVE", admin=admin_id, target=target_id, item=item_id) return {"ok": True, "action": "given", "item_id": item_id} elif action == "remove": if item_id not in items: return {"ok": False, "reason": "not_owned"} items.remove(item_id) item_uses.pop(item_id, None) user["items"] = items user["item_uses"] = item_uses await _commit(target_id, user) _txn("ADMIN_ITEM_REMOVE", admin=admin_id, target=target_id, item=item_id) return {"ok": True, "action": "removed", "item_id": item_id} return {"ok": False, "reason": "invalid_action"} # --------------------------------------------------------------------------- # /reminders # --------------------------------------------------------------------------- @_locked_by(0) 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 # --------------------------------------------------------------------------- @_locked_by(0) async def do_blackjack_bet(user_id: int, bet: int) -> dict: """Deduct the initial blackjack bet. Returns ok/fail.""" try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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"]} @_locked_by(0) 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.""" try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} 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. Per-user commit failures attempt to compensate the house so the economy stays balanced. If compensation also fails, a CRITICAL log is emitted. """ now = _now() payout_each = 0 failed_users: list[int] = [] if success and HOUSE_ID is not None: try: 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) # Atomic decrement (capped at the balance we read) instead of a full # record commit, so concurrent _credit_house increments aren't lost. debit = min(total, house["balance"]) if debit > 0: await pb_client.update_record(house["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item] except DatabaseError: return {"ok": False, "reason": "db_error"} _txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house["balance"] - debit) for uid in user_ids: async with _user_lock(uid): try: user = await get_user(uid) except DatabaseError: failed_users.append(uid) if success and payout_each > 0: await _refund_house_safe(payout_each, "heist_win_compensate", uid) continue user["last_heist"] = now.isoformat() user["heists_joined"] = user.get("heists_joined", 0) + 1 fine_credited = False 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: try: await _credit_house(fine) fine_credited = True except DatabaseError: pass # user commit will still be attempted; if both fail, no economy effect try: await _commit(uid, user) except DatabaseError: failed_users.append(uid) if success and payout_each > 0: await _refund_house_safe(payout_each, "heist_win_compensate", uid) elif not success and fine_credited: await _refund_user_safe(HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid) return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users} async def _refund_house_safe(amount: int, context: str, related_uid: int) -> None: """Best-effort refund of `amount` to the house. Logs critical if it fails.""" if HOUSE_ID is None or amount <= 0: return try: await _credit_house(amount) except DatabaseError as exc: _log.critical( "House compensation failed (%s, related uid %s, amount %s): %s", context, related_uid, amount, exc, ) async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None: """Best-effort atomic debit of `amount` from the house (compensates a failed user fine). Logs critical if it fails.""" if HOUSE_ID is None or amount <= 0: return try: record_id = await _house_record_id() if record_id: await pb_client.update_record(record_id, {"balance-": amount}) except DatabaseError as exc: _log.critical( "House debit compensation failed (%s, related uid %s, amount %s): %s", context, uid, amount, exc, )