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
This commit is contained in:
Rene Arumetsa
2026-09-04 02:44:46 +03:00
parent 8481003edf
commit b34c1e22da
9 changed files with 253 additions and 8 deletions

View File

@@ -31,6 +31,7 @@ __all__ = [
"buff_remaining",
"earn_mult",
"exp_buff_mult",
"grant_buff",
"do_buy_consumable",
]
@@ -116,6 +117,19 @@ def exp_buff_mult(user) -> float:
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
# ---------------------------------------------------------------------------
@@ -141,13 +155,7 @@ async def do_buy_consumable(user_id: int, cons_id: str) -> dict:
for field in _COOLDOWN_FIELDS:
user[field] = None
else:
buffs = dict(user.get("active_buffs") or {})
current = _parse_dt(buffs.get(cons["kind"]))
# Stack: extend from the current expiry if still active, else from now.
extended = current is not None and current > _now()
start = current if extended else _now()
buffs[cons["kind"]] = (start + timedelta(minutes=cons["duration_min"])).isoformat()
user["active_buffs"] = buffs
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"])