Vanity shop (/vanity): cosmetic badges/titles as a pure whale coin sink - purchases burn coins (not credited to the house) and equip a badge shown on /profile. New vanity_owned/vanity_active fields, schema sync, and tests. Money-safety and robustness fixes from a codebase review: - _parse_amount now rejects negative amounts. Every bet/give/request flows through it, so a negative value can no longer mint coins on a loss/transfer path that trusts the caller's sign (all call sites already guarded <= 0; this closes the source). - do_blackjack_payout no longer raises on a DB failure. The stake was already deducted in do_blackjack_bet, so it now logs critical with the owed amount (for admin reconciliation) and returns db_error; all payout call sites render a clear "payout failed" notice instead of crashing the interaction. - Instant "kohv" consumable now cancels the pending reminder DMs for the cooldowns it wipes (via new INSTANT_RESET_COMMANDS), so no stale/duplicate reminders fire. - Renamed the misleadingly-named _refund_user_safe -> _debit_house_safe (it debits the house) and dropped its ignored first arg. - Added __all__ to vanity.py and consumables.py so `import *` no longer leaks incidental imports into the economy namespace. - Documented Kõrvaklapid's +25 coin daily bonus in README and DEV_NOTES. Tests: blackjack payout DB-failure safety and INSTANT_RESET_COMMANDS lockstep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
110 lines
4.7 KiB
Python
110 lines
4.7 KiB
Python
"""Tests for the consumables shop (repeatable, expiring coin sink)."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from core import economy
|
|
|
|
from conftest import run
|
|
|
|
UID = 4242
|
|
|
|
|
|
def _fixed_now(monkeypatch, dt: datetime):
|
|
# _clock is the time seam shared by every economy submodule
|
|
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
|
|
return dt
|
|
|
|
|
|
def _fund(fake_pb, amount: int) -> None:
|
|
run(economy.get_user(UID))
|
|
fake_pb.record_for(UID)["balance"] = amount
|
|
|
|
|
|
class TestBuy:
|
|
def test_buy_earn_buff_deducts_and_activates(self, fake_pb):
|
|
_fund(fake_pb, 1000)
|
|
res = run(economy.do_buy_consumable(UID, "energy_xl"))
|
|
assert res["ok"] and not res["instant"]
|
|
assert res["balance"] == 500 # 1000 - 500 cost
|
|
user = run(economy.get_user(UID))
|
|
assert economy.earn_mult(user) == 2.0
|
|
assert economy.exp_buff_mult(user) == 1.0
|
|
|
|
def test_insufficient_funds_rejected(self, fake_pb):
|
|
_fund(fake_pb, 100)
|
|
res = run(economy.do_buy_consumable(UID, "energy_xl"))
|
|
assert not res["ok"] and res["reason"] == "insufficient"
|
|
assert res["need"] == 400
|
|
assert run(economy.get_user(UID))["balance"] == 100 # unchanged
|
|
|
|
def test_banned_rejected(self, fake_pb):
|
|
_fund(fake_pb, 1000)
|
|
fake_pb.record_for(UID)["eco_banned"] = True
|
|
res = run(economy.do_buy_consumable(UID, "energy_xl"))
|
|
assert not res["ok"] and res["reason"] == "banned"
|
|
|
|
def test_unknown_consumable(self, fake_pb):
|
|
_fund(fake_pb, 1000)
|
|
res = run(economy.do_buy_consumable(UID, "nope"))
|
|
assert not res["ok"] and res["reason"] == "not_found"
|
|
|
|
|
|
class TestBuffLifecycle:
|
|
def test_buff_expires(self, fake_pb, monkeypatch):
|
|
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
|
|
_fund(fake_pb, 1000)
|
|
run(economy.do_buy_consumable(UID, "energy_xl"))
|
|
assert economy.earn_mult(run(economy.get_user(UID))) == 2.0
|
|
# 61 minutes later the 60-minute buff is gone
|
|
_fixed_now(monkeypatch, t0 + timedelta(minutes=61))
|
|
assert economy.earn_mult(run(economy.get_user(UID))) == 1.0
|
|
|
|
def test_rebuy_extends_duration(self, fake_pb, monkeypatch):
|
|
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
|
|
_fund(fake_pb, 2000)
|
|
run(economy.do_buy_consumable(UID, "energy_xl")) # expiry = t0 + 60m
|
|
_fixed_now(monkeypatch, t0 + timedelta(minutes=30))
|
|
res = run(economy.do_buy_consumable(UID, "energy_xl")) # extends, not resets
|
|
assert res["extended"] is True
|
|
# remaining should be ~90m (30m left + 60m added), not 60m
|
|
assert res["remaining"] > timedelta(minutes=85)
|
|
|
|
|
|
class TestEffects:
|
|
def test_earn_buff_doubles_work(self, fake_pb, monkeypatch):
|
|
import random
|
|
_fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
|
|
_fund(fake_pb, 1000)
|
|
monkeypatch.setattr(random, "randint", lambda a, b: 50)
|
|
monkeypatch.setattr(random, "choice", lambda seq: seq[0])
|
|
monkeypatch.setattr(random, "random", lambda: 0.99) # no energiajook luck
|
|
base = run(economy.do_work(UID))["earned"]
|
|
fake_pb.record_for(UID)["last_work"] = None # clear cooldown
|
|
run(economy.do_buy_consumable(UID, "energy_xl"))
|
|
boosted = run(economy.do_work(UID))["earned"]
|
|
assert boosted == base * 2
|
|
|
|
def test_exp_buff_doubles_award(self, fake_pb):
|
|
_fund(fake_pb, 1000)
|
|
run(economy.do_buy_consumable(UID, "xp_potion"))
|
|
res = run(economy.award_exp(UID, 10))
|
|
assert res["gained"] == 20 # 10 * 2x exp buff
|
|
|
|
def test_kohv_clears_cooldowns(self, fake_pb, monkeypatch):
|
|
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
|
|
_fund(fake_pb, 1000)
|
|
rec = fake_pb.record_for(UID)
|
|
rec["last_work"] = t0.isoformat() # on cooldown
|
|
res = run(economy.do_buy_consumable(UID, "kohv"))
|
|
assert res["ok"] and res["instant"]
|
|
user = run(economy.get_user(UID))
|
|
assert user["last_work"] is None # cooldown wiped -> /work is ready
|
|
assert economy.store._cooldown_remaining(user, "work") is None
|
|
|
|
def test_instant_reset_commands_match_cleared_cooldowns(self):
|
|
# The Discord layer cancels reminder DMs for exactly these commands after a
|
|
# kohv, so the list must stay in lockstep with the cooldown fields it wipes.
|
|
expected = tuple(f.removeprefix("last_") for f in economy.consumables._COOLDOWN_FIELDS)
|
|
assert economy.INSTANT_RESET_COMMANDS == expected
|
|
assert economy.INSTANT_RESET_COMMANDS == ("work", "beg", "crime", "rob", "fish")
|