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:
Rene Arumetsa
2026-09-04 02:09:25 +03:00
parent a2e1601d0e
commit 037628d24f
21 changed files with 393 additions and 31 deletions

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}

View File

@@ -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:

View File

@@ -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
View 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"]}