Files
tipibot/core/economy/lootbox.py
Rene Arumetsa b34c1e22da feat(economy): add /lootbox mystery box (coin sink with random reward)
A pay-to-open box (1000 coins) with a weighted reward: coin tiers (usually a net
loss - the sink), a random 30-min earn/exp buff, or a rare jackpot. All rolling
lives in do_open_lootbox for testability; the command adds a short reveal.

- New core module lootbox.py; extracted consumables.grant_buff (reused by both
  consumables and lootbox) to avoid duplicating the buff-stacking logic.
- New lootboxes_opened stat; pending schema syncs as a number automatically.
- Added /consumables, /lootbox, /vanity to the help embed (the first two were
  previously missing from /help).

Tests cover charging, insufficient/banned, the coin and buff outcomes, the
net-vs-reward invariant, and that balance never goes negative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:44:46 +03:00

92 lines
2.9 KiB
Python

"""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"],
}