Merge pull request 'feat(economy): add /consumables shop of repeatable, expiring boosts' (#5) from fix/economy-money-safety into master

Reviewed-on: renkar/tipibot#5
This commit is contained in:
2026-08-19 17:53:12 +00:00
11 changed files with 371 additions and 5 deletions

View File

@@ -18,6 +18,7 @@ from .house import *
from .house import _credit_house, _house_record_id
from .levels import *
from .shop import *
from .consumables import *
from .fishing import *
from .quests import *
from .quests import _ensure_quests, _pick_quests, _quest_view
@@ -30,6 +31,6 @@ from .heist import *
from .admin import *
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
admin, fishing, gambling, heist, house, income, jail, leaderboards,
levels, prestige, quests, shop, store,
admin, consumables, fishing, gambling, heist, house, income, jail,
leaderboards, levels, prestige, quests, shop, store,
)

146
core/economy/consumables.py Normal file
View File

@@ -0,0 +1,146 @@
"""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"]),
}

View File

@@ -14,6 +14,7 @@ from .store import (
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user,
)
from .house import _credit_house
from .consumables import earn_mult
# ---------------------------------------------------------------------------
@@ -126,7 +127,7 @@ async def do_work(user_id: int) -> dict:
work_plus_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0)
work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"]
coin_mult, _ = _prestige_mult(user)
earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult)
earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult * earn_mult(user))
user["balance"] += earned
user["last_work"] = _now().isoformat()
user["work_count"] = user.get("work_count", 0) + 1
@@ -169,7 +170,7 @@ async def do_beg(user_id: int) -> dict:
jailed = bool(_is_jailed(user))
beg_mult = 2 if "klaviatuur" in user["items"] else 1
coin_mult, _ = _prestige_mult(user)
earned = int(random.randint(10, 40) * beg_mult * coin_mult)
earned = int(random.randint(10, 40) * beg_mult * coin_mult * earn_mult(user))
user["balance"] += earned
user["last_beg"] = _now().isoformat()
user["beg_count"] = user.get("beg_count", 0) + 1
@@ -217,6 +218,7 @@ async def do_crime(user_id: int) -> dict:
earned = random.randint(200, 500)
if "mikrofon" in user["items"]:
earned = int(earned * 1.3)
earned = int(earned * earn_mult(user))
user["balance"] += earned
user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import math
from .store import _locked_by, _prestige_mult, get_user, _commit
from .consumables import exp_buff_mult
# ---------------------------------------------------------------------------
@@ -69,7 +70,7 @@ async def award_exp(user_id: int, amount: int) -> dict:
"""Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp."""
user = await get_user(user_id)
_, exp_mult = _prestige_mult(user)
gained = max(1, int(amount * exp_mult))
gained = max(1, int(amount * exp_mult * exp_buff_mult(user)))
old_exp = user.get("exp", 0)
new_exp = old_exp + gained
old_level = get_level(old_exp)

View File

@@ -106,6 +106,7 @@ class UserData(TypedDict, total=False):
last_streak_date: str | None # ISO date "YYYY-MM-DD"
items: list[str]
item_uses: dict # {item_id: remaining_uses} for consumables
active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts
jailed_until: str | None # ISO datetime or None
jailbreak_used: bool
reminders: list[str] # command names user wants DM reminders for
@@ -159,6 +160,7 @@ def _default_user() -> UserData:
"last_streak_date": None,
"items": [],
"item_uses": {},
"active_buffs": {},
"jailed_until": None,
"jailbreak_used": False,
"reminders": ["daily", "work", "beg", "crime", "rob"],