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

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Callable from collections.abc import Callable
import discord import discord
@@ -322,6 +323,46 @@ def register_economy_support_commands(
msg = S.VANITY_UI["equipped"].format(emoji=v["emoji"], title=v["title"]) msg = S.VANITY_UI["equipped"].format(emoji=v["emoji"], title=v["title"])
await interaction.response.send_message(msg) await interaction.response.send_message(msg)
# -- /lootbox -----------------------------------------------------------
@tree.command(name="lootbox", description=S.CMD["lootbox"])
async def cmd_lootbox(interaction: discord.Interaction):
res = await economy.do_open_lootbox(interaction.user.id)
if not res["ok"]:
if res["reason"] == "db_error":
await reply_db_error(interaction)
elif res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
else:
await interaction.response.send_message(
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
)
return
# Suspense: show the box opening, then reveal the reward.
await interaction.response.send_message(
embed=discord.Embed(
title=S.LOOTBOX_UI["title"], description=S.LOOTBOX_UI["opening"], color=0xF4C430
)
)
msg = await interaction.original_response()
await asyncio.sleep(1.2)
if res["buff_kind"]:
line = S.LOOTBOX_UI["buff_" + res["buff_kind"]].format(min=res["buff_min"])
foot = S.LOOTBOX_UI["foot_buff"].format(balance=coin(res["balance"]))
color = 0x5865F2
else:
line = S.LOOTBOX_UI[res["outcome"]].format(coins=coin(res["reward_coins"]))
if res["net"] >= 0:
foot = S.LOOTBOX_UI["foot_win"].format(net=coin(res["net"]), balance=coin(res["balance"]))
color = 0x57F287
else:
foot = S.LOOTBOX_UI["foot_loss"].format(net=coin(abs(res["net"])), balance=coin(res["balance"]))
color = 0xF4C430 if res["outcome"] != "coins_small" else 0xED4245
embed = discord.Embed(title=S.LOOTBOX_UI["title"], description=line, color=color)
embed.set_footer(text=foot)
await msg.edit(embed=embed)
class RemindersSelect(discord.ui.Select): class RemindersSelect(discord.ui.Select):
def __init__(self, user_id: int, current: list[str]): def __init__(self, user_id: int, current: list[str]):
self.user_id = user_id self.user_id = user_id

View File

@@ -20,6 +20,7 @@ from .levels import *
from .shop import * from .shop import *
from .consumables import * from .consumables import *
from .vanity import * from .vanity import *
from .lootbox import *
from .fishing import * from .fishing import *
from .quests import * from .quests import *
from .quests import _ensure_quests, _pick_quests, _quest_view 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.) from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
admin, consumables, fishing, gambling, heist, house, income, jail, admin, consumables, fishing, gambling, heist, house, income, jail,
leaderboards, levels, prestige, quests, shop, store, vanity, leaderboards, levels, lootbox, prestige, quests, shop, store, vanity,
) )

View File

@@ -31,6 +31,7 @@ __all__ = [
"buff_remaining", "buff_remaining",
"earn_mult", "earn_mult",
"exp_buff_mult", "exp_buff_mult",
"grant_buff",
"do_buy_consumable", "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 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 # /consumables purchase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -141,13 +155,7 @@ async def do_buy_consumable(user_id: int, cons_id: str) -> dict:
for field in _COOLDOWN_FIELDS: for field in _COOLDOWN_FIELDS:
user[field] = None user[field] = None
else: else:
buffs = dict(user.get("active_buffs") or {}) extended = grant_buff(user, cons["kind"], cons["duration_min"])
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
await _commit(user_id, user) await _commit(user_id, user)
_txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"]) _txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"])

91
core/economy/lootbox.py Normal file
View 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"],
}

View File

@@ -152,6 +152,7 @@ class UserData(TypedDict, total=False):
total_given: int total_given: int
total_received: int total_received: int
best_daily_streak: int best_daily_streak: int
lootboxes_opened: int
heist_global_cd_until: float heist_global_cd_until: float
# Prestige system # Prestige system
prestige_level: int prestige_level: int
@@ -213,6 +214,7 @@ def _default_user() -> UserData:
"total_given": 0, "total_given": 0,
"total_received": 0, "total_received": 0,
"best_daily_streak": 0, "best_daily_streak": 0,
"lootboxes_opened": 0,
"heist_global_cd_until": 0.0, "heist_global_cd_until": 0.0,
# ── Prestige ───────────────────────────────────────────────────────── # ── Prestige ─────────────────────────────────────────────────────────
"prestige_level": 0, "prestige_level": 0,

View File

@@ -59,6 +59,7 @@ from .economy import (
CONSUMABLES_UI, CONSUMABLES_UI,
CONSUMABLE_DESCRIPTIONS, CONSUMABLE_DESCRIPTIONS,
VANITY_UI, VANITY_UI,
LOOTBOX_UI,
JAILED_UI, JAILED_UI,
SHOP_BTN, SHOP_BTN,
DAILY_UI, DAILY_UI,
@@ -152,6 +153,7 @@ __all__ = [
'CONSUMABLES_UI', 'CONSUMABLES_UI',
'CONSUMABLE_DESCRIPTIONS', 'CONSUMABLE_DESCRIPTIONS',
'VANITY_UI', 'VANITY_UI',
'LOOTBOX_UI',
'JAILED_UI', 'JAILED_UI',
'SHOP_BTN', 'SHOP_BTN',
'DAILY_UI', 'DAILY_UI',

View File

@@ -75,6 +75,7 @@ CMD: dict[str, str] = {
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad", "quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)", "consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
"vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid", "vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid",
"lootbox": "Ava õnnekast - juhuslik auhind müntide või boonuse näol",
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -153,6 +154,9 @@ HELP_CATEGORIES: dict[str, dict] = {
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"), ("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
("/shop", "Sirvi TipiBOTi poodi"), ("/shop", "Sirvi TipiBOTi poodi"),
("/buy <item>", "Osta ese TipiBOTi poodist"), ("/buy <item>", "Osta ese TipiBOTi poodist"),
("/consumables", "Osta korduvostetavaid turgutusi (ajutised boostid)."),
("/lootbox", "Ava õnnekast (1000 ⬡) - juhuslik auhind: mündid või ajutine boonus."),
("/vanity", "Staatusepood - osta ja kanna kosmeetilisi tiitleid (näha /profile-l)."),
("/request <amount> <reason> [target]", "Saada crowdfundingu taotlus. Keegi saab 'Toeta' nuppu vajutades raha kanda (taotlus kehtib 5 minutit)."), ("/request <amount> <reason> [target]", "Saada crowdfundingu taotlus. Keegi saab 'Toeta' nuppu vajutades raha kanda (taotlus kehtib 5 minutit)."),
("/reminders", "DM meeldetuletused on vaikimisi sees. Kasuta seda käsku, et lülitada sisse/välja, milliseid käsklusi meelde tuletada."), ("/reminders", "DM meeldetuletused on vaikimisi sees. Kasuta seda käsku, et lülitada sisse/välja, milliseid käsklusi meelde tuletada."),
], ],

View File

@@ -18,6 +18,7 @@ __all__ = [
'CONSUMABLES_UI', 'CONSUMABLES_UI',
'CONSUMABLE_DESCRIPTIONS', 'CONSUMABLE_DESCRIPTIONS',
'VANITY_UI', 'VANITY_UI',
'LOOTBOX_UI',
'JAILED_UI', 'JAILED_UI',
'SHOP_BTN', 'SHOP_BTN',
'DAILY_UI', 'DAILY_UI',
@@ -257,6 +258,23 @@ VANITY_UI: dict[str, str] = {
"unequipped": "Märk eemaldatud - su profiil on jälle tavaline.", "unequipped": "Märk eemaldatud - su profiil on jälle tavaline.",
} }
# ---------------------------------------------------------------------------
# Lootbox (pay-to-open mystery box, a coin sink with a random reward)
# ---------------------------------------------------------------------------
LOOTBOX_UI: dict[str, str] = {
"title": "🎁 Õnnekast",
"opening": "🎁 Avan õnnekasti...",
"coins_small": "🪙 Leidsid põhjast paar münti: **+{coins}**",
"coins_medium": "💰 Korralik saak: **+{coins}**",
"coins_big": "💎 Suur õnn: **+{coins}**",
"jackpot": "🎉 **JACKPOT!** **+{coins}**",
"buff_earn": "⚡ Boonus: teenimine **×2** järgmiseks **{min} minutiks**!",
"buff_exp": "✨ Boonus: EXP **×2** järgmiseks **{min} minutiks**!",
"foot_win": "Netovõit: +{net} · Saldo: {balance}",
"foot_loss": "Netokahjum: {net} · Saldo: {balance}",
"foot_buff": "Saldo: {balance}",
}
JAILED_UI: dict[str, str] = { JAILED_UI: dict[str, str] = {
"title": "🔒 Praegu vanglas", "title": "🔒 Praegu vanglas",
"empty": "Kõik on vabad! Vanglas pole kedagi.", "empty": "Kõik on vabad! Vanglas pole kedagi.",

78
tests/test_lootbox.py Normal file
View File

@@ -0,0 +1,78 @@
"""Tests for the /lootbox mystery box (pay-to-open coin sink)."""
import random
from core import economy
from conftest import run
UID = 8080
def _fund(fake_pb, amount: int) -> None:
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = amount
def _open_until(fake_pb, predicate):
"""Open boxes with varied seeds until `predicate(res)` holds; returns that res."""
for seed in range(1000):
random.seed(seed)
_fund(fake_pb, 100_000)
res = run(economy.do_open_lootbox(UID))
if predicate(res):
return res
raise AssertionError("outcome never occurred")
class TestOpen:
def test_costs_the_fee_and_charges_up_front(self, fake_pb):
_fund(fake_pb, 1_000)
random.seed(1)
res = run(economy.do_open_lootbox(UID))
assert res["ok"]
# balance == 1000 - cost + reward_coins
assert res["balance"] == 1_000 - economy.LOOTBOX_COST + res["reward_coins"]
def test_insufficient_rejected(self, fake_pb):
_fund(fake_pb, economy.LOOTBOX_COST - 1)
res = run(economy.do_open_lootbox(UID))
assert not res["ok"] and res["reason"] == "insufficient"
assert res["need"] == 1
assert fake_pb.record_for(UID)["balance"] == economy.LOOTBOX_COST - 1 # not charged
def test_banned_rejected(self, fake_pb):
_fund(fake_pb, 5_000)
fake_pb.record_for(UID)["eco_banned"] = True
res = run(economy.do_open_lootbox(UID))
assert not res["ok"] and res["reason"] == "banned"
def test_increments_counter(self, fake_pb):
_fund(fake_pb, 5_000)
random.seed(3)
run(economy.do_open_lootbox(UID))
run(economy.do_open_lootbox(UID))
assert run(economy.get_user(UID))["lootboxes_opened"] == 2
class TestOutcomes:
def test_coin_outcome_credits_and_net_matches(self, fake_pb):
res = _open_until(fake_pb, lambda r: r["reward_coins"] > 0)
assert res["net"] == res["reward_coins"] - economy.LOOTBOX_COST
assert res["buff_kind"] is None
def test_buff_outcome_grants_active_buff(self, fake_pb):
res = _open_until(fake_pb, lambda r: r["buff_kind"] is not None)
assert res["reward_coins"] == 0
assert res["net"] == -economy.LOOTBOX_COST
user = run(economy.get_user(UID))
# the granted buff is live
assert res["buff_kind"] in economy.active_buffs(user)
def test_never_negative_balance(self, fake_pb):
# Open exactly at the cost floor repeatedly; balance must stay >= 0.
for seed in range(30):
random.seed(seed)
_fund(fake_pb, economy.LOOTBOX_COST)
res = run(economy.do_open_lootbox(UID))
assert res["balance"] >= 0