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:
@@ -245,7 +245,7 @@ The house is listed at **#0** on the leaderboard. Players can attempt to rob it
|
||||
|
||||
| Command | Cooldown | Base payout | Notes |
|
||||
|---|---|---|---|
|
||||
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
|
||||
| `/daily` | 20h | 150 ⬡ | Streak multiplier applied (see below). Kõrvaklapid reduces cooldown to 18h and adds +25 ⬡. LAN pilet doubles the reward. Bot Farm adds 5% interest on your balance (capped at 500 ⬡/day). Prestige daily_plus adds +20% base per upgrade level. |
|
||||
| `/work` | 1h | 15–75 ⬡ | Random job flavour text. Mängurihiir +50%, Reguleeritav laud +25% (stacks). Red Bull: 30% chance of ×3. Ultralai monitor reduces cooldown to 40min. Prestige work_plus adds +20% per upgrade level. |
|
||||
| `/beg` | 5min | 10–40 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. |
|
||||
| `/crime` | 2h | 200–500 ⬡ | 60% success rate (75% with Cat6 kaabel). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Gaming tool skips jail on fail. |
|
||||
@@ -424,7 +424,7 @@ All items are **permanent** once purchased **except Anticheat**, which expires a
|
||||
| Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
|
||||
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 |
|
||||
| Anticheat | 1 000 ⬡ | Rob attempts against you fail and fine the robber. **2 uses**, then repurchase. |
|
||||
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h |
|
||||
| Kõrvaklapid | 1 200 ⬡ | `/daily` cooldown 20h → 18h and +25 ⬡ bonus |
|
||||
| LAN pilet | 1 200 ⬡ | `/daily` reward ×2 |
|
||||
| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |
|
||||
|
||||
|
||||
11
bot.py
11
bot.py
@@ -688,8 +688,10 @@ register_prestige_commands(
|
||||
|
||||
def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
||||
"""Parse an amount string; 'all' resolves to the user's full balance.
|
||||
Accepts plain integers and valid thousand-separated numbers (1,000 / 1.000 / 1 000).
|
||||
Rejects decimals and ambiguous inputs like 1,1 or 1.5.
|
||||
Accepts plain non-negative integers and valid thousand-separated numbers
|
||||
(1,000 / 1.000 / 1 000). Rejects decimals, ambiguous inputs like 1,1 or 1.5,
|
||||
and negative amounts (a negative bet/give would mint coins on loss/transfer
|
||||
paths that trust the caller's sign).
|
||||
Returns (amount, None) on success or (None, error_msg) on failure."""
|
||||
v = value.strip()
|
||||
if v.lower() == "all":
|
||||
@@ -698,9 +700,12 @@ def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
||||
if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v):
|
||||
v = re.sub(r'[,. ]', '', v)
|
||||
try:
|
||||
return int(v), None
|
||||
amount = int(v)
|
||||
except ValueError:
|
||||
return None, S.ERR["invalid_amount"]
|
||||
if amount < 0:
|
||||
return None, S.ERR["invalid_amount"]
|
||||
return amount, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -926,13 +926,17 @@ def register_economy_games_commands(
|
||||
embed = self._cur_embed(game_over=True, hand_results=hand_results)
|
||||
embed.title = S.TITLE[title_key]
|
||||
embed.color = color
|
||||
if res.get("ok"):
|
||||
result_line = result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"]))
|
||||
else:
|
||||
result_line = result_str + "\n" + S.ERR["payout_failed"]
|
||||
embed.add_field(
|
||||
name=S.BJ["result_field"],
|
||||
value=result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"])),
|
||||
value=result_line,
|
||||
inline=False,
|
||||
)
|
||||
await self.message.edit(embed=embed, view=self)
|
||||
if total_payout > total_invested:
|
||||
if res.get("ok") and total_payout > total_invested:
|
||||
asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested)))
|
||||
|
||||
async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None:
|
||||
@@ -1150,31 +1154,36 @@ def register_economy_games_commands(
|
||||
await asyncio.sleep(_BJ_DEAL_DELAY)
|
||||
if _bj_is_blackjack(dealer_hand):
|
||||
push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet)
|
||||
push_line = (
|
||||
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"]))
|
||||
if push_res.get("ok")
|
||||
else S.BJ["push_result"] + "\n" + S.ERR["payout_failed"]
|
||||
)
|
||||
embed = _bj_embed(
|
||||
player_hand,
|
||||
dealer_hand,
|
||||
S.TITLE["blackjack_push"],
|
||||
0x99AAB5,
|
||||
hide_dealer=False,
|
||||
result_field=(
|
||||
S.BJ["result_field"],
|
||||
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"])),
|
||||
),
|
||||
result_field=(S.BJ["result_field"], push_line),
|
||||
)
|
||||
else:
|
||||
payout = bet + int(bet * 1.5)
|
||||
bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet)
|
||||
bj_line = (
|
||||
f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"]))
|
||||
if bj_res.get("ok")
|
||||
else f"+{coin(payout)}" + "\n" + S.ERR["payout_failed"]
|
||||
)
|
||||
embed = _bj_embed(
|
||||
player_hand,
|
||||
dealer_hand,
|
||||
S.TITLE["blackjack_bj"],
|
||||
0xF4C430,
|
||||
hide_dealer=False,
|
||||
result_field=(
|
||||
S.BJ["result_field"],
|
||||
f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"])),
|
||||
),
|
||||
result_field=(S.BJ["result_field"], bj_line),
|
||||
)
|
||||
if bj_res.get("ok"):
|
||||
asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet)))
|
||||
active_games.discard(interaction.user.id)
|
||||
await msg.edit(embed=embed)
|
||||
|
||||
@@ -29,10 +29,15 @@ def register_economy_profile_commands(
|
||||
pct = progress / needed if needed > 0 else 1.0
|
||||
filled = int(pct * 12)
|
||||
bar = "█" * filled + "░" * (12 - filled)
|
||||
embed = discord.Embed(
|
||||
title=S.PROFILE_UI["main_title"].format(name=target.display_name),
|
||||
color=0xF4C430,
|
||||
badge = economy.vanity_badge(data)
|
||||
title = (
|
||||
f"{badge[0]} {target.display_name}"
|
||||
if badge
|
||||
else S.PROFILE_UI["main_title"].format(name=target.display_name)
|
||||
)
|
||||
embed = discord.Embed(title=title, color=0xF4C430)
|
||||
if badge:
|
||||
embed.set_author(name=badge[1])
|
||||
embed.add_field(name=S.PROFILE_UI["f_balance"], value=coin(data.get("balance", 0)), inline=True)
|
||||
embed.add_field(
|
||||
name=S.PROFILE_UI["f_level"],
|
||||
|
||||
@@ -224,6 +224,10 @@ def register_economy_support_commands(
|
||||
|
||||
cons = res["consumable"]
|
||||
if res["instant"]:
|
||||
# Kohv wiped these cooldowns, so any reminder DM already scheduled for
|
||||
# them is now stale - cancel it (the next command run reschedules).
|
||||
for cmd in economy.INSTANT_RESET_COMMANDS:
|
||||
cancel_reminder_task(interaction.user.id, cmd)
|
||||
desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"]))
|
||||
else:
|
||||
key = "bought_extended" if res["extended"] else "bought_buff"
|
||||
@@ -238,6 +242,75 @@ def register_economy_support_commands(
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
# -- /vanity ------------------------------------------------------------
|
||||
def _vanity_embed(user_data: dict) -> discord.Embed:
|
||||
owned = set(user_data.get("vanity_owned") or [])
|
||||
active = user_data.get("vanity_active")
|
||||
embed = discord.Embed(
|
||||
title=S.VANITY_UI["title"],
|
||||
description=S.VANITY_UI["desc"].format(bal=coin(user_data.get("balance", 0))),
|
||||
color=0xF4C430,
|
||||
)
|
||||
for vid, v in economy.VANITY.items():
|
||||
if vid == active:
|
||||
status = S.VANITY_UI["line_active"]
|
||||
elif vid in owned:
|
||||
status = S.VANITY_UI["line_owned"]
|
||||
else:
|
||||
status = f"{v['cost']} {economy.COIN}"
|
||||
embed.add_field(
|
||||
name=f"{v['emoji']} {v['name']} · {status}",
|
||||
value=S.VANITY_UI["entry_title"].format(title=v["title"]),
|
||||
inline=False,
|
||||
)
|
||||
embed.set_footer(text=S.VANITY_UI["footer"])
|
||||
return embed
|
||||
|
||||
@tree.command(name="vanity", description=S.CMD["vanity"])
|
||||
@app_commands.describe(ese=S.OPT["vanity_ese"])
|
||||
@app_commands.choices(
|
||||
ese=[
|
||||
app_commands.Choice(name=f"{v['emoji']} {v['title']} ({v['cost']} TipiCOINi)", value=vid)
|
||||
for vid, v in economy.VANITY.items()
|
||||
]
|
||||
+ [app_commands.Choice(name=S.VANITY_UI["none_choice"], value=economy.vanity.NONE_ID)]
|
||||
)
|
||||
async def cmd_vanity(
|
||||
interaction: discord.Interaction,
|
||||
ese: app_commands.Choice[str] | None = None,
|
||||
):
|
||||
if ese is None:
|
||||
data = await economy.get_user(interaction.user.id)
|
||||
await interaction.response.send_message(
|
||||
embed=_vanity_embed(data), ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
res = await economy.do_vanity_select(interaction.user.id, ese.value)
|
||||
if not res["ok"]:
|
||||
if res["reason"] == "banned":
|
||||
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||
elif res["reason"] == "insufficient":
|
||||
await interaction.response.send_message(
|
||||
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True)
|
||||
return
|
||||
|
||||
action = res["action"]
|
||||
if action == "unequipped":
|
||||
await interaction.response.send_message(S.VANITY_UI["unequipped"], ephemeral=True)
|
||||
return
|
||||
v = res["vanity"]
|
||||
if action == "bought":
|
||||
msg = S.VANITY_UI["bought"].format(
|
||||
emoji=v["emoji"], title=v["title"], balance=coin(res["balance"])
|
||||
)
|
||||
else:
|
||||
msg = S.VANITY_UI["equipped"].format(emoji=v["emoji"], title=v["title"])
|
||||
await interaction.response.send_message(msg)
|
||||
|
||||
class RemindersSelect(discord.ui.Select):
|
||||
def __init__(self, user_id: int, current: list[str]):
|
||||
self.user_id = user_id
|
||||
|
||||
@@ -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."""
|
||||
"""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))
|
||||
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"]}
|
||||
@@ -116,7 +116,7 @@ All economy state is stored in **PocketBase** (`economy_users` collection). `cor
|
||||
|
||||
| Command | Cooldown | Base Earn | Notes |
|
||||
|---|---|---|---|
|
||||
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +5% interest w/ gaming_laptop |
|
||||
| `/daily` | 20h (18h w/ korvaklapid) | 150⬡ | ×streak multiplier, ×2 w/ lan_pass, +25⬡ w/ korvaklapid, +5% interest w/ gaming_laptop |
|
||||
| `/work` | 1h (40min w/ monitor) | 15-75⬡ | ×1.5 w/ gaming_hiir, ×1.25 w/ reguleeritav_laud, ×3 30% chance w/ energiajook |
|
||||
| `/beg` | 5min (3min w/ hiirematt) | 10-40⬡ | ×2 w/ klaviatuur |
|
||||
| `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail |
|
||||
|
||||
@@ -34,10 +34,12 @@ from core import economy # noqa: E402
|
||||
PB_URL = config.PB_URL
|
||||
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY})
|
||||
|
||||
# _default_user() keys whose default is None, all ISO date/datetime strings
|
||||
# _default_user() keys with a None/str default that PocketBase stores as text
|
||||
# (ISO date/datetime strings, plus the vanity badge id).
|
||||
_TEXT_FIELDS = {
|
||||
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
|
||||
"last_heist", "last_fish", "last_streak_date", "jailed_until",
|
||||
"vanity_active",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ from .economy import (
|
||||
ITEM_DESCRIPTIONS,
|
||||
CONSUMABLES_UI,
|
||||
CONSUMABLE_DESCRIPTIONS,
|
||||
VANITY_UI,
|
||||
JAILED_UI,
|
||||
SHOP_BTN,
|
||||
DAILY_UI,
|
||||
@@ -150,6 +151,7 @@ __all__ = [
|
||||
'ITEM_DESCRIPTIONS',
|
||||
'CONSUMABLES_UI',
|
||||
'CONSUMABLE_DESCRIPTIONS',
|
||||
'VANITY_UI',
|
||||
'JAILED_UI',
|
||||
'SHOP_BTN',
|
||||
'DAILY_UI',
|
||||
|
||||
@@ -74,6 +74,7 @@ CMD: dict[str, str] = {
|
||||
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
|
||||
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
|
||||
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
|
||||
"vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -92,6 +93,7 @@ OPT: dict[str, str] = {
|
||||
"give_summa": "Kui palju annad? ('all' = kogu saldo)",
|
||||
"buy_ese": "Eseme nimi (vaata /shop)",
|
||||
"consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)",
|
||||
"vanity_ese": "Tiitel, mida osta või kanda (tühjaks jättes näeb poodi)",
|
||||
"rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)",
|
||||
"rps_vastane": "Väljakutse teisele mängijale (PvP)",
|
||||
"slots_panus": "Panus TipiCOINides ('all' = kogu saldo)",
|
||||
|
||||
@@ -175,6 +175,7 @@ ERR: dict[str, str] = {
|
||||
"guild_only": "Seda käsku saab kasutada ainult serveris.",
|
||||
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
|
||||
"gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.",
|
||||
"payout_failed": "⚠️ Tehniline viga võidu väljamaksmisel - see on logitud ja admin taastab su TipiCOINid. Vabandame!",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,6 +17,7 @@ __all__ = [
|
||||
'ITEM_DESCRIPTIONS',
|
||||
'CONSUMABLES_UI',
|
||||
'CONSUMABLE_DESCRIPTIONS',
|
||||
'VANITY_UI',
|
||||
'JAILED_UI',
|
||||
'SHOP_BTN',
|
||||
'DAILY_UI',
|
||||
@@ -240,6 +241,22 @@ CONSUMABLES_UI: dict[str, str] = {
|
||||
"bought_instant": "☕ Kõik ooteajad nullitud!\nUus saldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vanity shop (cosmetic badges/titles - a pure status sink, no gameplay effect)
|
||||
# ---------------------------------------------------------------------------
|
||||
VANITY_UI: dict[str, str] = {
|
||||
"title": "👑 Staatusepood",
|
||||
"desc": "Puhtalt uhkuse pärast - märgid ja tiitlid ilma igasuguse mänguefektita. Kantud märk paistab su `/profile`-l.\nSaldo: {bal}",
|
||||
"line_owned": "✅ Olemas",
|
||||
"line_active": "⭐ Kantud",
|
||||
"entry_title": "„{title}“",
|
||||
"footer": "Osta või kanna: /vanity <märk> · „Eemalda märk“ võtab tiitli maha",
|
||||
"none_choice": "❌ Eemalda märk",
|
||||
"bought": "{emoji} Ostsid tiitli **{title}** ja panid selle kohe kandma!\nUus saldo: {balance}",
|
||||
"equipped": "{emoji} Kannad nüüd tiitlit **{title}**.",
|
||||
"unequipped": "Märk eemaldatud - su profiil on jälle tavaline.",
|
||||
}
|
||||
|
||||
JAILED_UI: dict[str, str] = {
|
||||
"title": "🔒 Praegu vanglas",
|
||||
"empty": "Kõik on vabad! Vanglas pole kedagi.",
|
||||
|
||||
@@ -100,3 +100,10 @@ class TestEffects:
|
||||
user = run(economy.get_user(UID))
|
||||
assert user["last_work"] is None # cooldown wiped -> /work is ready
|
||||
assert economy.store._cooldown_remaining(user, "work") is None
|
||||
|
||||
def test_instant_reset_commands_match_cleared_cooldowns(self):
|
||||
# The Discord layer cancels reminder DMs for exactly these commands after a
|
||||
# kohv, so the list must stay in lockstep with the cooldown fields it wipes.
|
||||
expected = tuple(f.removeprefix("last_") for f in economy.consumables._COOLDOWN_FIELDS)
|
||||
assert economy.INSTANT_RESET_COMMANDS == expected
|
||||
assert economy.INSTANT_RESET_COMMANDS == ("work", "beg", "crime", "rob", "fish")
|
||||
|
||||
@@ -10,6 +10,7 @@ Covers the two pure-logic fixes:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from core import economy
|
||||
from core.pb_client import DatabaseError
|
||||
|
||||
from conftest import run
|
||||
|
||||
@@ -89,3 +90,26 @@ class TestBailIdempotency:
|
||||
self._jail(fake_pb, now, balance=1000)
|
||||
res = run(economy.do_bail(UID))
|
||||
assert res["ok"] and res["fine"] >= economy.MIN_BAIL
|
||||
|
||||
|
||||
class TestBlackjackPayoutSafety:
|
||||
"""do_blackjack_payout must not raise on a DB failure - the stake was already
|
||||
deducted in do_blackjack_bet, so a raised exception would blow up the
|
||||
interaction handler and swallow the outcome. It reports db_error instead."""
|
||||
|
||||
async def _boom(self, *args, **kwargs):
|
||||
raise DatabaseError("simulated PocketBase outage")
|
||||
|
||||
def test_payout_returns_db_error_when_read_fails(self, fake_pb, monkeypatch):
|
||||
monkeypatch.setattr(economy.gambling, "get_user", self._boom)
|
||||
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||
assert res == {"ok": False, "reason": "db_error"}
|
||||
|
||||
def test_payout_returns_db_error_when_commit_fails(self, fake_pb, monkeypatch):
|
||||
run(economy.get_user(UID))
|
||||
fake_pb.record_for(UID)["balance"] = 500
|
||||
monkeypatch.setattr(economy.gambling, "_commit", self._boom)
|
||||
res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100))
|
||||
assert res == {"ok": False, "reason": "db_error"}
|
||||
# The failed credit did not persist; balance is untouched (no partial win).
|
||||
assert fake_pb.record_for(UID)["balance"] == 500
|
||||
|
||||
78
tests/test_vanity.py
Normal file
78
tests/test_vanity.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Tests for the vanity shop (cosmetic status sink, no gameplay effect)."""
|
||||
|
||||
from core import economy
|
||||
|
||||
from conftest import run
|
||||
|
||||
UID = 7777
|
||||
|
||||
|
||||
def _fund(fake_pb, amount: int) -> None:
|
||||
run(economy.get_user(UID))
|
||||
fake_pb.record_for(UID)["balance"] = amount
|
||||
|
||||
|
||||
class TestBuy:
|
||||
def test_buy_burns_coins_owns_and_equips(self, fake_pb):
|
||||
_fund(fake_pb, 10_000)
|
||||
cost = economy.VANITY["couch"]["cost"]
|
||||
res = run(economy.do_vanity_select(UID, "couch"))
|
||||
assert res["ok"] and res["action"] == "bought"
|
||||
assert res["balance"] == 10_000 - cost
|
||||
user = run(economy.get_user(UID))
|
||||
assert "couch" in user["vanity_owned"]
|
||||
assert user["vanity_active"] == "couch"
|
||||
assert economy.vanity_badge(user) == ("🎮", "Sohvasõdur")
|
||||
|
||||
def test_coins_are_destroyed_not_sent_to_house(self, fake_pb, monkeypatch):
|
||||
_fund(fake_pb, 10_000)
|
||||
credited = []
|
||||
monkeypatch.setattr(economy.house, "_credit_house", lambda amt: credited.append(amt))
|
||||
run(economy.do_vanity_select(UID, "couch"))
|
||||
assert credited == [] # nothing recirculated to the house
|
||||
|
||||
def test_insufficient_funds_rejected(self, fake_pb):
|
||||
_fund(fake_pb, 100)
|
||||
res = run(economy.do_vanity_select(UID, "legend"))
|
||||
assert not res["ok"] and res["reason"] == "insufficient"
|
||||
assert res["need"] == economy.VANITY["legend"]["cost"] - 100
|
||||
assert run(economy.get_user(UID))["balance"] == 100 # unchanged
|
||||
|
||||
def test_banned_rejected(self, fake_pb):
|
||||
_fund(fake_pb, 10_000)
|
||||
fake_pb.record_for(UID)["eco_banned"] = True
|
||||
res = run(economy.do_vanity_select(UID, "couch"))
|
||||
assert not res["ok"] and res["reason"] == "banned"
|
||||
|
||||
def test_unknown_badge(self, fake_pb):
|
||||
_fund(fake_pb, 10_000)
|
||||
res = run(economy.do_vanity_select(UID, "nope"))
|
||||
assert not res["ok"] and res["reason"] == "not_found"
|
||||
|
||||
|
||||
class TestEquip:
|
||||
def test_equip_owned_is_free(self, fake_pb):
|
||||
_fund(fake_pb, 20_000)
|
||||
run(economy.do_vanity_select(UID, "couch")) # buy + equip couch
|
||||
run(economy.do_vanity_select(UID, "cables")) # buy + equip cables
|
||||
bal_before = run(economy.get_user(UID))["balance"]
|
||||
res = run(economy.do_vanity_select(UID, "couch")) # re-equip owned couch
|
||||
assert res["ok"] and res["action"] == "equipped"
|
||||
user = run(economy.get_user(UID))
|
||||
assert user["vanity_active"] == "couch"
|
||||
assert user["balance"] == bal_before # no re-charge
|
||||
|
||||
def test_unequip_clears_badge(self, fake_pb):
|
||||
_fund(fake_pb, 10_000)
|
||||
run(economy.do_vanity_select(UID, "couch"))
|
||||
res = run(economy.do_vanity_select(UID, economy.vanity.NONE_ID))
|
||||
assert res["ok"] and res["action"] == "unequipped"
|
||||
user = run(economy.get_user(UID))
|
||||
assert user["vanity_active"] is None
|
||||
assert economy.vanity_badge(user) is None
|
||||
assert "couch" in user["vanity_owned"] # still owned, just not worn
|
||||
|
||||
|
||||
def test_no_badge_by_default(fake_pb):
|
||||
user = run(economy.get_user(UID))
|
||||
assert economy.vanity_badge(user) is None
|
||||
Reference in New Issue
Block a user