"""Consumables: repeatable, expiring boosts - a recurring coin sink. Unlike SHOP items (permanent, bought once), consumables are bought over and over. Buying either activates a timed buff (stored in user["active_buffs"] as {kind: expiry_iso}) or applies an instant effect, and the coins are destroyed - so this keeps the shop relevant, and the economy draining, after a player has maxed out permanent gear. Effect hooks live where the effect belongs: - "earn" buff -> income.do_work / do_beg / do_crime (via earn_mult) - "exp" buff -> levels.award_exp (via exp_buff_mult) - instant kohv -> handled entirely here (wipes cooldown timestamps) """ from __future__ import annotations from datetime import timedelta from typing import TypedDict import strings from ..pb_client import DatabaseError from ..emoji import EMOJI as E from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user __all__ = [ "Consumable", "CONSUMABLES", "INSTANT_RESET_COMMANDS", "active_buffs", "buff_remaining", "earn_mult", "exp_buff_mult", "grant_buff", "do_buy_consumable", ] class Consumable(TypedDict): name: str emoji: str cost: int description: str kind: str # active_buffs key ("earn"/"exp"), or "instant" duration_min: int # buff lifetime in minutes (0 for instant effects) CONSUMABLES: dict[str, Consumable] = { "energy_xl": { "name": "Energiajook XL", "emoji": E["TipiBULL"], "cost": 500, "description": strings.CONSUMABLE_DESCRIPTIONS["energy_xl"], "kind": "earn", "duration_min": 60, }, "xp_potion": { "name": "XP jook", "emoji": "✨", "cost": 500, "description": strings.CONSUMABLE_DESCRIPTIONS["xp_potion"], "kind": "exp", "duration_min": 60, }, "kohv": { "name": "Kohv", "emoji": "☕", "cost": 300, "description": strings.CONSUMABLE_DESCRIPTIONS["kohv"], "kind": "instant", "duration_min": 0, }, } # Multiplier granted while a buff of each kind is active. _BUFF_MULT: dict[str, float] = {"earn": 2.0, "exp": 2.0} # Cooldown timestamps an instant "kohv" clears. _COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish") # The slash-command names whose cooldowns an instant "kohv" clears. The Discord # layer uses these to cancel any pending reminder DMs, since the cooldowns they # were scheduled for no longer exist. INSTANT_RESET_COMMANDS = tuple(f.removeprefix("last_") for f in _COOLDOWN_FIELDS) # --------------------------------------------------------------------------- # Buff inspection helpers (pure - safe to call while holding a user lock) # --------------------------------------------------------------------------- def active_buffs(user) -> dict[str, str]: """Return {kind: expiry_iso} for buffs that have not expired yet.""" buffs = user.get("active_buffs") or {} now = _now() out: dict[str, str] = {} for kind, expiry in buffs.items(): dt = _parse_dt(expiry) if dt is not None and dt > now: out[kind] = expiry return out def buff_remaining(user, kind: str) -> timedelta | None: """Remaining time on a buff kind, or None if it is inactive.""" expiry = active_buffs(user).get(kind) if expiry is None: return None return _parse_dt(expiry) - _now() def earn_mult(user) -> float: """Earnings multiplier from an active 'earn' buff (1.0 if none).""" return _BUFF_MULT["earn"] if "earn" in active_buffs(user) else 1.0 def exp_buff_mult(user) -> float: """EXP multiplier from an active 'exp' buff (1.0 if none).""" return _BUFF_MULT["exp"] if "exp" in active_buffs(user) else 1.0 def grant_buff(user, kind: str, duration_min: int) -> bool: """Add/extend a timed buff of `kind` on `user` in place. Stacks: extends from the current expiry if still active, else starts now. Returns True if it extended an existing buff. Pure - safe under a user lock (caller commits).""" buffs = dict(user.get("active_buffs") or {}) current = _parse_dt(buffs.get(kind)) extended = current is not None and current > _now() start = current if extended else _now() buffs[kind] = (start + timedelta(minutes=duration_min)).isoformat() user["active_buffs"] = buffs return extended # --------------------------------------------------------------------------- # /consumables purchase # --------------------------------------------------------------------------- @_locked_by(0) async def do_buy_consumable(user_id: int, cons_id: str) -> dict: if cons_id not in CONSUMABLES: 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"} cons = CONSUMABLES[cons_id] if user["balance"] < cons["cost"]: return {"ok": False, "reason": "insufficient", "need": cons["cost"] - user["balance"]} user["balance"] -= cons["cost"] extended = False if cons["kind"] == "instant": for field in _COOLDOWN_FIELDS: user[field] = None else: extended = grant_buff(user, cons["kind"], cons["duration_min"]) await _commit(user_id, user) _txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"]) return { "ok": True, "consumable": cons, "balance": user["balance"], "instant": cons["kind"] == "instant", "extended": extended, "remaining": None if cons["kind"] == "instant" else buff_remaining(user, cons["kind"]), }