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:
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user