"""Mystery box (/lootbox): a pay-to-open coin sink with a weighted reward. Buying always burns LOOTBOX_COST; the reward is usually worth less than the cost (a deliberate sink, like consumables/vanity) but occasionally pays out big or grants a timed buff. All randomness lives in do_open_lootbox so it stays a single, testable core function; the command layer only renders the result. """ from __future__ import annotations import random from ..pb_client import DatabaseError from .store import _commit, _locked_by, _txn, get_user from .consumables import grant_buff __all__ = ["LOOTBOX_COST", "do_open_lootbox"] LOOTBOX_COST = 1_000 _BUFF_DURATION_MIN = 30 # (weight, outcome_key). Weights need not sum to 100. Coin outcomes roll an # amount in the ranges below; "buff" grants a random 30-min earn/exp boost. _OUTCOMES: list[tuple[int, str]] = [ (42, "coins_small"), # usually a net loss - the sink (28, "coins_medium"), (15, "buff"), (10, "coins_big"), (5, "jackpot"), ] _COIN_RANGES: dict[str, tuple[int, int]] = { "coins_small": (50, 500), "coins_medium": (500, 1_200), "coins_big": (1_200, 2_500), "jackpot": (4_000, 9_000), } _BUFF_KINDS = ("earn", "exp") @_locked_by(0) async def do_open_lootbox(user_id: int) -> dict: """Charge LOOTBOX_COST and grant one weighted reward. Returns the outcome.""" 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 user["balance"] < LOOTBOX_COST: return {"ok": False, "reason": "insufficient", "need": LOOTBOX_COST - user["balance"]} user["balance"] -= LOOTBOX_COST outcome = random.choices( [k for _, k in _OUTCOMES], weights=[w for w, _ in _OUTCOMES], k=1 )[0] reward_coins = 0 buff_kind = None if outcome == "buff": buff_kind = random.choice(_BUFF_KINDS) grant_buff(user, buff_kind, _BUFF_DURATION_MIN) else: lo, hi = _COIN_RANGES[outcome] reward_coins = random.randint(lo, hi) user["balance"] += reward_coins user["lifetime_earned"] = user.get("lifetime_earned", 0) + reward_coins user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) if outcome == "jackpot": user["biggest_win"] = max(user.get("biggest_win", 0), reward_coins) net = reward_coins - LOOTBOX_COST user["lootboxes_opened"] = user.get("lootboxes_opened", 0) + 1 await _commit(user_id, user) _txn( "LOOTBOX", user=user_id, outcome=outcome, reward=f"+{reward_coins}" if reward_coins else (buff_kind or "-"), net=f"{net:+}", bal=user["balance"], ) return { "ok": True, "outcome": outcome, "reward_coins": reward_coins, "buff_kind": buff_kind, "buff_min": _BUFF_DURATION_MIN if buff_kind else 0, "net": net, "balance": user["balance"], }