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:
Rene Arumetsa
2026-08-19 20:32:36 +03:00
parent b9b4c7c4c7
commit a2e1601d0e
11 changed files with 371 additions and 5 deletions

102
tests/test_consumables.py Normal file
View 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