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>
147 lines
4.9 KiB
Python
147 lines
4.9 KiB
Python
"""Consumables: repeatable, expiring boosts - a recurring coin sink.
|
|
|
|
Unlike SHOP items (permanent, bought once), consumables are bought over and
|
|
over. Buying either activates a timed buff (stored in user["active_buffs"] as
|
|
{kind: expiry_iso}) or applies an instant effect, and the coins are destroyed -
|
|
so this keeps the shop relevant, and the economy draining, after a player has
|
|
maxed out permanent gear.
|
|
|
|
Effect hooks live where the effect belongs:
|
|
- "earn" buff -> income.do_work / do_beg / do_crime (via earn_mult)
|
|
- "exp" buff -> levels.award_exp (via exp_buff_mult)
|
|
- instant kohv -> handled entirely here (wipes cooldown timestamps)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
from typing import TypedDict
|
|
|
|
import strings
|
|
|
|
from ..pb_client import DatabaseError
|
|
from ..emoji import EMOJI as E
|
|
from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user
|
|
|
|
|
|
class Consumable(TypedDict):
|
|
name: str
|
|
emoji: str
|
|
cost: int
|
|
description: str
|
|
kind: str # active_buffs key ("earn"/"exp"), or "instant"
|
|
duration_min: int # buff lifetime in minutes (0 for instant effects)
|
|
|
|
|
|
CONSUMABLES: dict[str, Consumable] = {
|
|
"energy_xl": {
|
|
"name": "Energiajook XL",
|
|
"emoji": E["TipiBULL"],
|
|
"cost": 500,
|
|
"description": strings.CONSUMABLE_DESCRIPTIONS["energy_xl"],
|
|
"kind": "earn",
|
|
"duration_min": 60,
|
|
},
|
|
"xp_potion": {
|
|
"name": "XP jook",
|
|
"emoji": "✨",
|
|
"cost": 500,
|
|
"description": strings.CONSUMABLE_DESCRIPTIONS["xp_potion"],
|
|
"kind": "exp",
|
|
"duration_min": 60,
|
|
},
|
|
"kohv": {
|
|
"name": "Kohv",
|
|
"emoji": "☕",
|
|
"cost": 300,
|
|
"description": strings.CONSUMABLE_DESCRIPTIONS["kohv"],
|
|
"kind": "instant",
|
|
"duration_min": 0,
|
|
},
|
|
}
|
|
|
|
# Multiplier granted while a buff of each kind is active.
|
|
_BUFF_MULT: dict[str, float] = {"earn": 2.0, "exp": 2.0}
|
|
|
|
# Cooldown timestamps an instant "kohv" clears.
|
|
_COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Buff inspection helpers (pure - safe to call while holding a user lock)
|
|
# ---------------------------------------------------------------------------
|
|
def active_buffs(user) -> dict[str, str]:
|
|
"""Return {kind: expiry_iso} for buffs that have not expired yet."""
|
|
buffs = user.get("active_buffs") or {}
|
|
now = _now()
|
|
out: dict[str, str] = {}
|
|
for kind, expiry in buffs.items():
|
|
dt = _parse_dt(expiry)
|
|
if dt is not None and dt > now:
|
|
out[kind] = expiry
|
|
return out
|
|
|
|
|
|
def buff_remaining(user, kind: str) -> timedelta | None:
|
|
"""Remaining time on a buff kind, or None if it is inactive."""
|
|
expiry = active_buffs(user).get(kind)
|
|
if expiry is None:
|
|
return None
|
|
return _parse_dt(expiry) - _now()
|
|
|
|
|
|
def earn_mult(user) -> float:
|
|
"""Earnings multiplier from an active 'earn' buff (1.0 if none)."""
|
|
return _BUFF_MULT["earn"] if "earn" in active_buffs(user) else 1.0
|
|
|
|
|
|
def exp_buff_mult(user) -> float:
|
|
"""EXP multiplier from an active 'exp' buff (1.0 if none)."""
|
|
return _BUFF_MULT["exp"] if "exp" in active_buffs(user) else 1.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /consumables purchase
|
|
# ---------------------------------------------------------------------------
|
|
@_locked_by(0)
|
|
async def do_buy_consumable(user_id: int, cons_id: str) -> dict:
|
|
if cons_id not in CONSUMABLES:
|
|
return {"ok": False, "reason": "not_found"}
|
|
try:
|
|
user = await get_user(user_id)
|
|
except DatabaseError:
|
|
return {"ok": False, "reason": "db_error"}
|
|
if user.get("eco_banned"):
|
|
return {"ok": False, "reason": "banned"}
|
|
|
|
cons = CONSUMABLES[cons_id]
|
|
if user["balance"] < cons["cost"]:
|
|
return {"ok": False, "reason": "insufficient", "need": cons["cost"] - user["balance"]}
|
|
|
|
user["balance"] -= cons["cost"]
|
|
|
|
extended = False
|
|
if cons["kind"] == "instant":
|
|
for field in _COOLDOWN_FIELDS:
|
|
user[field] = None
|
|
else:
|
|
buffs = dict(user.get("active_buffs") or {})
|
|
current = _parse_dt(buffs.get(cons["kind"]))
|
|
# Stack: extend from the current expiry if still active, else from now.
|
|
extended = current is not None and current > _now()
|
|
start = current if extended else _now()
|
|
buffs[cons["kind"]] = (start + timedelta(minutes=cons["duration_min"])).isoformat()
|
|
user["active_buffs"] = buffs
|
|
|
|
await _commit(user_id, user)
|
|
_txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"])
|
|
|
|
return {
|
|
"ok": True,
|
|
"consumable": cons,
|
|
"balance": user["balance"],
|
|
"instant": cons["kind"] == "instant",
|
|
"extended": extended,
|
|
"remaining": None if cons["kind"] == "instant" else buff_remaining(user, cons["kind"]),
|
|
}
|