forked from sass/tipibot
feat(economy): add vanity shop and harden economy money-safety
Vanity shop (/vanity): cosmetic badges/titles as a pure whale coin sink - purchases burn coins (not credited to the house) and equip a badge shown on /profile. New vanity_owned/vanity_active fields, schema sync, and tests. Money-safety and robustness fixes from a codebase review: - _parse_amount now rejects negative amounts. Every bet/give/request flows through it, so a negative value can no longer mint coins on a loss/transfer path that trusts the caller's sign (all call sites already guarded <= 0; this closes the source). - do_blackjack_payout no longer raises on a DB failure. The stake was already deducted in do_blackjack_bet, so it now logs critical with the owed amount (for admin reconciliation) and returns db_error; all payout call sites render a clear "payout failed" notice instead of crashing the interaction. - Instant "kohv" consumable now cancels the pending reminder DMs for the cooldowns it wipes (via new INSTANT_RESET_COMMANDS), so no stale/duplicate reminders fire. - Renamed the misleadingly-named _refund_user_safe -> _debit_house_safe (it debits the house) and dropped its ignored first arg. - Added __all__ to vanity.py and consumables.py so `import *` no longer leaks incidental imports into the economy namespace. - Documented Kõrvaklapid's +25 coin daily bonus in README and DEV_NOTES. Tests: blackjack payout DB-failure safety and INSTANT_RESET_COMMANDS lockstep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
This commit is contained in:
@@ -19,6 +19,7 @@ from .house import _credit_house, _house_record_id
|
||||
from .levels import *
|
||||
from .shop import *
|
||||
from .consumables import *
|
||||
from .vanity import *
|
||||
from .fishing import *
|
||||
from .quests import *
|
||||
from .quests import _ensure_quests, _pick_quests, _quest_view
|
||||
@@ -32,5 +33,5 @@ from .admin import *
|
||||
|
||||
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
|
||||
admin, consumables, fishing, gambling, heist, house, income, jail,
|
||||
leaderboards, levels, prestige, quests, shop, store,
|
||||
leaderboards, levels, prestige, quests, shop, store, vanity,
|
||||
)
|
||||
|
||||
@@ -23,6 +23,17 @@ 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",
|
||||
"do_buy_consumable",
|
||||
]
|
||||
|
||||
|
||||
class Consumable(TypedDict):
|
||||
name: str
|
||||
@@ -66,6 +77,11 @@ _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)
|
||||
|
||||
@@ -6,7 +6,7 @@ import random
|
||||
|
||||
from ..pb_client import DatabaseError
|
||||
from ..emoji import EMOJI as E
|
||||
from .store import _commit, _is_jailed, _locked_by, _txn, get_user
|
||||
from .store import _commit, _is_jailed, _locked_by, _log, _txn, get_user
|
||||
from .house import _credit_house
|
||||
|
||||
|
||||
@@ -254,8 +254,15 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict:
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
|
||||
"""Credit the net payout. House receives the difference when payout < total_invested."""
|
||||
user = await get_user(user_id)
|
||||
"""Credit the net payout. House receives the difference when payout < total_invested.
|
||||
|
||||
The stake was already deducted in do_blackjack_bet, so a DB failure here must
|
||||
not raise through the interaction handler and swallow the player's winnings:
|
||||
report db_error like every other mutation so the caller can surface it."""
|
||||
try:
|
||||
user = await get_user(user_id)
|
||||
except DatabaseError:
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
user["balance"] += payout
|
||||
user["balance"] = max(0, user["balance"])
|
||||
user["total_wagered"] = user.get("total_wagered", 0) + total_invested
|
||||
@@ -267,7 +274,17 @@ async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0
|
||||
elif net < 0:
|
||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
|
||||
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
|
||||
await _commit(user_id, user)
|
||||
try:
|
||||
await _commit(user_id, user)
|
||||
except DatabaseError:
|
||||
# The stake is already gone (deducted in do_blackjack_bet) and this credit
|
||||
# did not persist. Nothing to roll back locally - log the owed amount so an
|
||||
# admin can reconcile with /admincoins.
|
||||
_log.critical(
|
||||
"blackjack payout commit failed for %s: owed payout=%s (invested=%s)",
|
||||
user_id, payout, total_invested,
|
||||
)
|
||||
return {"ok": False, "reason": "db_error"}
|
||||
house_gain = total_invested - payout
|
||||
if house_gain > 0:
|
||||
await _credit_house(house_gain)
|
||||
|
||||
@@ -8,7 +8,7 @@ from .. import pb_client
|
||||
from ..pb_client import DatabaseError
|
||||
from . import house
|
||||
from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user
|
||||
from .house import _credit_house, _refund_house_safe, _refund_user_safe
|
||||
from .house import _credit_house, _refund_house_safe, _debit_house_safe
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -98,6 +98,6 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
||||
if success and payout_each > 0:
|
||||
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||
elif not success and fine_credited:
|
||||
await _refund_user_safe(house.HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
||||
await _debit_house_safe(fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
||||
|
||||
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
|
||||
|
||||
@@ -75,9 +75,10 @@ async def _refund_house_safe(amount: int, context: str, related_uid: int) -> Non
|
||||
)
|
||||
|
||||
|
||||
async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
|
||||
"""Best-effort atomic debit of `amount` from the house (compensates a failed
|
||||
user fine). Logs critical if it fails."""
|
||||
async def _debit_house_safe(amount: int, context: str, uid: int) -> None:
|
||||
"""Best-effort atomic debit of `amount` from the house. Compensates a fine
|
||||
that was credited to the house but whose matching user debit failed to persist
|
||||
(the coins must be pulled back out of the house). Logs critical if it fails."""
|
||||
if HOUSE_ID is None or amount <= 0:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -107,6 +107,8 @@ class UserData(TypedDict, total=False):
|
||||
items: list[str]
|
||||
item_uses: dict # {item_id: remaining_uses} for consumables
|
||||
active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts
|
||||
vanity_owned: list[str] # cosmetic badge ids the user has purchased
|
||||
vanity_active: str | None # currently-equipped vanity badge id (shown on /profile)
|
||||
jailed_until: str | None # ISO datetime or None
|
||||
jailbreak_used: bool
|
||||
reminders: list[str] # command names user wants DM reminders for
|
||||
@@ -161,6 +163,8 @@ def _default_user() -> UserData:
|
||||
"items": [],
|
||||
"item_uses": {},
|
||||
"active_buffs": {},
|
||||
"vanity_owned": [],
|
||||
"vanity_active": None,
|
||||
"jailed_until": None,
|
||||
"jailbreak_used": False,
|
||||
"reminders": ["daily", "work", "beg", "crime", "rob"],
|
||||
|
||||
98
core/economy/vanity.py
Normal file
98
core/economy/vanity.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Vanity shop: cosmetic badges/titles - a pure status sink for the wealthy.
|
||||
|
||||
No gameplay effect whatsoever. Buying burns the coins (they are NOT credited
|
||||
to the house, so they leave circulation for good) and unlocks a badge the
|
||||
player can equip; the equipped badge shows on /profile. Prices scale steeply
|
||||
on purpose - this is where the richest players dump coins for bragging rights,
|
||||
draining the top end of the economy where inflation hurts most.
|
||||
|
||||
A single entry point, do_vanity_select, handles buy / equip / unequip so the
|
||||
whole feature fits one slash command.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
from ..pb_client import DatabaseError
|
||||
from .store import _commit, _locked_by, _txn, get_user
|
||||
|
||||
__all__ = [
|
||||
"Vanity",
|
||||
"VANITY",
|
||||
"NONE_ID",
|
||||
"vanity_badge",
|
||||
"do_vanity_select",
|
||||
]
|
||||
|
||||
|
||||
class Vanity(TypedDict):
|
||||
name: str # short shop name
|
||||
emoji: str # the badge shown next to the player
|
||||
title: str # the flavour title shown on /profile
|
||||
cost: int
|
||||
|
||||
|
||||
# Ordered cheapest -> priciest: a LAN/gaming status ladder from casual couch
|
||||
# gamer to LAN legend. The top rungs are the deliberate whale sink.
|
||||
VANITY: dict[str, Vanity] = {
|
||||
"couch": {"name": "Sohvapadi", "emoji": "🎮", "title": "Sohvasõdur", "cost": 5_000},
|
||||
"cables": {"name": "Võrgukaabel", "emoji": "🔌", "title": "Kaablihaldur", "cost": 8_000},
|
||||
"discord": {"name": "Peakomplekt", "emoji": "🎧", "title": "Discordi Admin", "cost": 12_000},
|
||||
"aimbot": {"name": "Kahtlane Hiir", "emoji": "🖱️", "title": "Aimbot Kahtlusalune", "cost": 18_000},
|
||||
"sniper": {"name": "360Hz Monitor", "emoji": "🎯", "title": "Snaipripüss", "cost": 25_000},
|
||||
"champ": {"name": "Meistrikarikas", "emoji": "🏆", "title": "Turniirivõitja", "cost": 40_000},
|
||||
"legend": {"name": "Mängurijaam", "emoji": "🖥️", "title": "LAN Legend", "cost": 75_000},
|
||||
}
|
||||
|
||||
# Sentinel choice value that unequips the active badge.
|
||||
NONE_ID = "none"
|
||||
|
||||
|
||||
def vanity_badge(user) -> tuple[str, str] | None:
|
||||
"""Return (emoji, title) for the user's equipped badge, or None."""
|
||||
vid = user.get("vanity_active")
|
||||
vanity = VANITY.get(vid) if vid else None
|
||||
return (vanity["emoji"], vanity["title"]) if vanity else None
|
||||
|
||||
|
||||
@_locked_by(0)
|
||||
async def do_vanity_select(user_id: int, vanity_id: str) -> dict:
|
||||
"""Buy (if unowned), equip (if owned), or unequip (NONE_ID) a badge."""
|
||||
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"}
|
||||
|
||||
if vanity_id == NONE_ID:
|
||||
user["vanity_active"] = None
|
||||
await _commit(user_id, user)
|
||||
_txn("VANITY_UNEQUIP", user=user_id)
|
||||
return {"ok": True, "action": "unequipped"}
|
||||
|
||||
if vanity_id not in VANITY:
|
||||
return {"ok": False, "reason": "not_found"}
|
||||
|
||||
owned = list(user.get("vanity_owned") or [])
|
||||
vanity = VANITY[vanity_id]
|
||||
|
||||
# Already owned -> just equip it (free).
|
||||
if vanity_id in owned:
|
||||
user["vanity_active"] = vanity_id
|
||||
await _commit(user_id, user)
|
||||
_txn("VANITY_EQUIP", user=user_id, item=vanity_id)
|
||||
return {"ok": True, "action": "equipped", "vanity": vanity}
|
||||
|
||||
# Not owned -> purchase (burns the coins) and auto-equip.
|
||||
if user["balance"] < vanity["cost"]:
|
||||
return {"ok": False, "reason": "insufficient", "need": vanity["cost"] - user["balance"]}
|
||||
|
||||
user["balance"] -= vanity["cost"] # burned: not credited to the house
|
||||
owned.append(vanity_id)
|
||||
user["vanity_owned"] = owned
|
||||
user["vanity_active"] = vanity_id
|
||||
await _commit(user_id, user)
|
||||
_txn("VANITY_BUY", user=user_id, item=vanity_id, cost=f"-{vanity['cost']}", bal=user["balance"])
|
||||
return {"ok": True, "action": "bought", "vanity": vanity, "balance": user["balance"]}
|
||||
Reference in New Issue
Block a user