A pay-to-open box (1000 coins) with a weighted reward: coin tiers (usually a net loss - the sink), a random 30-min earn/exp buff, or a rare jackpot. All rolling lives in do_open_lootbox for testability; the command adds a short reveal. - New core module lootbox.py; extracted consumables.grant_buff (reused by both consumables and lootbox) to avoid duplicating the buff-stacking logic. - New lootboxes_opened stat; pending schema syncs as a number automatically. - Added /consumables, /lootbox, /vanity to the help embed (the first two were previously missing from /help). Tests cover charging, insufficient/banned, the coin and buff outcomes, the net-vs-reward invariant, and that balance never goes negative. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
171 lines
5.6 KiB
Python
171 lines
5.6 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
|
|
|
|
__all__ = [
|
|
"Consumable",
|
|
"CONSUMABLES",
|
|
"INSTANT_RESET_COMMANDS",
|
|
"active_buffs",
|
|
"buff_remaining",
|
|
"earn_mult",
|
|
"exp_buff_mult",
|
|
"grant_buff",
|
|
"do_buy_consumable",
|
|
]
|
|
|
|
|
|
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")
|
|
|
|
# The slash-command names whose cooldowns an instant "kohv" clears. The Discord
|
|
# layer uses these to cancel any pending reminder DMs, since the cooldowns they
|
|
# were scheduled for no longer exist.
|
|
INSTANT_RESET_COMMANDS = tuple(f.removeprefix("last_") for f in _COOLDOWN_FIELDS)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
def grant_buff(user, kind: str, duration_min: int) -> bool:
|
|
"""Add/extend a timed buff of `kind` on `user` in place. Stacks: extends from
|
|
the current expiry if still active, else starts now. Returns True if it
|
|
extended an existing buff. Pure - safe under a user lock (caller commits)."""
|
|
buffs = dict(user.get("active_buffs") or {})
|
|
current = _parse_dt(buffs.get(kind))
|
|
extended = current is not None and current > _now()
|
|
start = current if extended else _now()
|
|
buffs[kind] = (start + timedelta(minutes=duration_min)).isoformat()
|
|
user["active_buffs"] = buffs
|
|
return extended
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /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:
|
|
extended = grant_buff(user, cons["kind"], cons["duration_min"])
|
|
|
|
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"]),
|
|
}
|