forked from sass/tipibot
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:
@@ -20,6 +20,7 @@ from .levels import *
|
||||
from .shop import *
|
||||
from .consumables import *
|
||||
from .vanity import *
|
||||
from .lootbox import *
|
||||
from .fishing import *
|
||||
from .quests import *
|
||||
from .quests import _ensure_quests, _pick_quests, _quest_view
|
||||
@@ -33,5 +34,5 @@ from .admin import *
|
||||
|
||||
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
|
||||
admin, consumables, fishing, gambling, heist, house, income, jail,
|
||||
leaderboards, levels, prestige, quests, shop, store, vanity,
|
||||
leaderboards, levels, lootbox, prestige, quests, shop, store, vanity,
|
||||
)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
91
core/economy/lootbox.py
Normal file
91
core/economy/lootbox.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""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"],
|
||||
}
|
||||
@@ -152,6 +152,7 @@ class UserData(TypedDict, total=False):
|
||||
total_given: int
|
||||
total_received: int
|
||||
best_daily_streak: int
|
||||
lootboxes_opened: int
|
||||
heist_global_cd_until: float
|
||||
# Prestige system
|
||||
prestige_level: int
|
||||
@@ -213,6 +214,7 @@ def _default_user() -> UserData:
|
||||
"total_given": 0,
|
||||
"total_received": 0,
|
||||
"best_daily_streak": 0,
|
||||
"lootboxes_opened": 0,
|
||||
"heist_global_cd_until": 0.0,
|
||||
# ── Prestige ─────────────────────────────────────────────────────────
|
||||
"prestige_level": 0,
|
||||
|
||||
Reference in New Issue
Block a user