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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
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_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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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."),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
78
tests/test_lootbox.py
Normal 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
|
||||||
Reference in New Issue
Block a user