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:
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