"""Shared foundation: user records, locks, time, cooldowns, txn log.""" from __future__ import annotations import asyncio import functools import logging from datetime import datetime, timedelta, timezone from typing import TypedDict import aiohttp from .. import pb_client from ..pb_client import DatabaseError from ..emoji import EMOJI as E def _clock() -> datetime: """Actual time source - a seam so tests can freeze time everywhere at once.""" return datetime.now(tz=timezone.utc) def _now() -> datetime: return _clock() _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 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, }, } # --------------------------------------------------------------------------- # 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), } # Items that shorten a command's cooldown: command -> (item_id, reduced cooldown). # Single source of truth so the cooldown check (do_*), the reminder scheduler # (_maybe_remind) and the restart restore (_restore_reminders) never drift apart. ITEM_COOLDOWNS: dict[str, tuple[str, timedelta]] = { "work": ("monitor", timedelta(minutes=40)), "beg": ("hiirematt", timedelta(minutes=3)), "daily": ("korvaklapid", timedelta(hours=18)), "fish": ("ussipurk", timedelta(seconds=90)), } def effective_cooldown(cmd: str, items) -> timedelta | None: """The cooldown for `cmd` given the user's owned `items`, applying any item-based reduction. Returns None for commands with no cooldown.""" override = ITEM_COOLDOWNS.get(cmd) if override is not None and override[0] in items: return override[1] return COOLDOWNS.get(cmd) JAIL_DURATION = timedelta(minutes=30) HEIST_JAIL = timedelta(hours=1, minutes=30) # --------------------------------------------------------------------------- # User schema # --------------------------------------------------------------------------- class UserData(TypedDict, total=False): balance: int # liquid coins - spendable, gamblable, robbable bank_balance: int # vaulted coins - safe from /rob and /heist, not spendable until withdrawn 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 active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts vanity_owned: list[str] # cosmetic badge ids the user has purchased vanity_active: str | None # currently-equipped vanity badge id (shown on /profile) 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 lootboxes_opened: int achievements_earned: list # ids of achievements already claimed lottery_tickets: int # tickets held for the current lottery period lottery_period: str | None # draw-date the held tickets count for (ISO date) 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}}} # Coins a running interactive game (blackjack/RPS PvP) has deducted but not # yet settled. {"kind": ..., "amount": int, "ts": iso} while escrowed, {} # otherwise. Reconciled (refunded) on startup so a restart mid-game never # eats the stake. See reconcile_pending_wagers. pending_wager: dict def _default_user() -> UserData: return { "balance": 0, "bank_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": {}, "active_buffs": {}, "vanity_owned": [], "vanity_active": None, "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, "lootboxes_opened": 0, "achievements_earned": [], "lottery_tickets": 0, "lottery_period": None, "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": {}, # ── Interactive-game escrow (blackjack / RPS PvP) ──────────────────── "pending_wager": {}, } # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- # 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 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 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"], ) # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- # Pending-wager escrow (interactive games survive a restart) # --------------------------------------------------------------------------- # Interactive games (blackjack, RPS PvP) deduct a stake up front and hold it in # an in-memory View until the hand resolves. A restart would drop the View and # lose the coins. To prevent that, the deduction commit also records the escrowed # amount on the user (add_pending_wager), the settlement commit clears it # (clear_pending_wager), and reconcile_pending_wagers refunds anything still # outstanding at startup. All three mutate the user dict in place so the escrow # state rides along in the SAME commit as the balance change (atomic). def add_pending_wager(user: UserData, kind: str, amount: int) -> None: """Record/accumulate `amount` coins as escrowed by a `kind` game.""" pw = dict(user.get("pending_wager") or {}) pw = { "kind": kind, "amount": int(pw.get("amount", 0) or 0) + amount, "ts": _now().isoformat(), } user["pending_wager"] = pw def clear_pending_wager(user: UserData) -> None: """Mark the user's escrow settled (call in the settlement commit).""" user["pending_wager"] = {} async def reconcile_pending_wagers() -> list[tuple[int, int, str]]: """Refund every stake left escrowed by a game that a restart interrupted. Runs once at startup (before commands are served). Returns the list of (user_id, refunded_amount, kind) so the caller can log a summary.""" refunded: list[tuple[int, int, str]] = [] for uid_str, snapshot in (await get_all_users_raw()).items(): pw = snapshot.get("pending_wager") or {} if int(pw.get("amount", 0) or 0) <= 0: continue uid = int(uid_str) async with _user_lock(uid): user = await get_user(uid) pw = user.get("pending_wager") or {} amount = int(pw.get("amount", 0) or 0) if amount <= 0: continue kind = str(pw.get("kind", "?")) user["balance"] += amount clear_pending_wager(user) await _commit(uid, user) _txn("WAGER_RECONCILE", user=uid, refund=f"+{amount}", kind=kind, bal=user["balance"]) _log.info("Refunded interrupted %s wager: %s coins to user %s", kind, amount, uid) refunded.append((uid, amount, kind)) return refunded # --------------------------------------------------------------------------- # /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)