forked from sass/tipibot
feat(economy): add vanity shop and harden economy money-safety
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
This commit is contained in:
@@ -100,3 +100,10 @@ class TestEffects:
|
||||
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")
|
||||
|
||||
@@ -10,6 +10,7 @@ Covers the two pure-logic fixes:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from core import economy
|
||||
from core.pb_client import DatabaseError
|
||||
|
||||
from conftest import run
|
||||
|
||||
@@ -89,3 +90,26 @@ class TestBailIdempotency:
|
||||
self._jail(fake_pb, now, balance=1000)
|
||||
res = run(economy.do_bail(UID))
|
||||
assert res["ok"] and res["fine"] >= economy.MIN_BAIL
|
||||
|
||||
|
||||
class TestBlackjackPayoutSafety:
|
||||
"""do_blackjack_payout must not raise on a DB failure - the stake was already
|
||||
deducted in do_blackjack_bet, so a raised exception would blow up the
|
||||
interaction handler and swallow the outcome. It reports db_error instead."""
|
||||
|
||||
async def _boom(self, *args, **kwargs):
|
||||
raise DatabaseError("simulated PocketBase outage")
|
||||
|
||||
def test_payout_returns_db_error_when_read_fails(self, fake_pb, monkeypatch):
|
||||
monkeypatch.setattr(economy.gambling, "get_user", self._boom)
|
||||
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||
assert res == {"ok": False, "reason": "db_error"}
|
||||
|
||||
def test_payout_returns_db_error_when_commit_fails(self, fake_pb, monkeypatch):
|
||||
run(economy.get_user(UID))
|
||||
fake_pb.record_for(UID)["balance"] = 500
|
||||
monkeypatch.setattr(economy.gambling, "_commit", self._boom)
|
||||
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||
assert res == {"ok": False, "reason": "db_error"}
|
||||
# The failed credit did not persist; balance is untouched (no partial win).
|
||||
assert fake_pb.record_for(UID)["balance"] == 500
|
||||
|
||||
78
tests/test_vanity.py
Normal file
78
tests/test_vanity.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Tests for the vanity shop (cosmetic status sink, no gameplay effect)."""
|
||||
|
||||
from core import economy
|
||||
|
||||
from conftest import run
|
||||
|
||||
UID = 7777
|
||||
|
||||
|
||||
def _fund(fake_pb, amount: int) -> None:
|
||||
run(economy.get_user(UID))
|
||||
fake_pb.record_for(UID)["balance"] = amount
|
||||
|
||||
|
||||
class TestBuy:
|
||||
def test_buy_burns_coins_owns_and_equips(self, fake_pb):
|
||||
_fund(fake_pb, 10_000)
|
||||
cost = economy.VANITY["couch"]["cost"]
|
||||
res = run(economy.do_vanity_select(UID, "couch"))
|
||||
assert res["ok"] and res["action"] == "bought"
|
||||
assert res["balance"] == 10_000 - cost
|
||||
user = run(economy.get_user(UID))
|
||||
assert "couch" in user["vanity_owned"]
|
||||
assert user["vanity_active"] == "couch"
|
||||
assert economy.vanity_badge(user) == ("🎮", "Sohvasõdur")
|
||||
|
||||
def test_coins_are_destroyed_not_sent_to_house(self, fake_pb, monkeypatch):
|
||||
_fund(fake_pb, 10_000)
|
||||
credited = []
|
||||
monkeypatch.setattr(economy.house, "_credit_house", lambda amt: credited.append(amt))
|
||||
run(economy.do_vanity_select(UID, "couch"))
|
||||
assert credited == [] # nothing recirculated to the house
|
||||
|
||||
def test_insufficient_funds_rejected(self, fake_pb):
|
||||
_fund(fake_pb, 100)
|
||||
res = run(economy.do_vanity_select(UID, "legend"))
|
||||
assert not res["ok"] and res["reason"] == "insufficient"
|
||||
assert res["need"] == economy.VANITY["legend"]["cost"] - 100
|
||||
assert run(economy.get_user(UID))["balance"] == 100 # unchanged
|
||||
|
||||
def test_banned_rejected(self, fake_pb):
|
||||
_fund(fake_pb, 10_000)
|
||||
fake_pb.record_for(UID)["eco_banned"] = True
|
||||
res = run(economy.do_vanity_select(UID, "couch"))
|
||||
assert not res["ok"] and res["reason"] == "banned"
|
||||
|
||||
def test_unknown_badge(self, fake_pb):
|
||||
_fund(fake_pb, 10_000)
|
||||
res = run(economy.do_vanity_select(UID, "nope"))
|
||||
assert not res["ok"] and res["reason"] == "not_found"
|
||||
|
||||
|
||||
class TestEquip:
|
||||
def test_equip_owned_is_free(self, fake_pb):
|
||||
_fund(fake_pb, 20_000)
|
||||
run(economy.do_vanity_select(UID, "couch")) # buy + equip couch
|
||||
run(economy.do_vanity_select(UID, "cables")) # buy + equip cables
|
||||
bal_before = run(economy.get_user(UID))["balance"]
|
||||
res = run(economy.do_vanity_select(UID, "couch")) # re-equip owned couch
|
||||
assert res["ok"] and res["action"] == "equipped"
|
||||
user = run(economy.get_user(UID))
|
||||
assert user["vanity_active"] == "couch"
|
||||
assert user["balance"] == bal_before # no re-charge
|
||||
|
||||
def test_unequip_clears_badge(self, fake_pb):
|
||||
_fund(fake_pb, 10_000)
|
||||
run(economy.do_vanity_select(UID, "couch"))
|
||||
res = run(economy.do_vanity_select(UID, economy.vanity.NONE_ID))
|
||||
assert res["ok"] and res["action"] == "unequipped"
|
||||
user = run(economy.get_user(UID))
|
||||
assert user["vanity_active"] is None
|
||||
assert economy.vanity_badge(user) is None
|
||||
assert "couch" in user["vanity_owned"] # still owned, just not worn
|
||||
|
||||
|
||||
def test_no_badge_by_default(fake_pb):
|
||||
user = run(economy.get_user(UID))
|
||||
assert economy.vanity_badge(user) is None
|
||||
Reference in New Issue
Block a user