forked from sass/tipibot
Merge pull request 'feat(economy): add /consumables shop of repeatable, expiring boosts' (#5) from fix/economy-money-safety into master
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -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
146
core/economy/consumables.py
Normal 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"]),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user