forked from sass/tipibot
feat(economy): add /consumables shop of repeatable, expiring boosts
The economy had many coin faucets but almost no sinks: the /shop is
one-time ownership, and gambling/rob fines route to the house (which
players drain back via jackpots and heists), so they recirculate rather
than destroy coins. Result: steady inflation.
Add a consumables shop as a true recurring sink - buying destroys the
coins and grants a temporary boost, so there's always something to spend
on after gear is maxed:
- Energiajook XL (500) - 1h of 2x earnings on /work, /beg, /crime
- XP jook (500) - 1h of 2x EXP
- Kohv (300) - instantly clears all cooldowns
Timed buffs live in a new active_buffs field ({kind: expiry_iso}), pruned
on read; rebuying extends the timer. Effects hook where they belong:
earn_mult in income.do_work/do_beg/do_crime, exp_buff_mult in
levels.award_exp; kohv is self-contained. New /consumables command browses
the menu (with active buffs) or buys a boost. Covered by 9 tests.
Note: active_buffs is a new PocketBase field - run
scripts/sync_pb_schema.py before deploying or buffs won't persist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
102
tests/test_consumables.py
Normal file
102
tests/test_consumables.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user