forked from sass/tipibot
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>
103 lines
4.2 KiB
Python
103 lines
4.2 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
|