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

@@ -245,7 +245,7 @@ The house is listed at **#0** on the leaderboard. Players can attempt to rob it
| Command | Cooldown | Base payout | Notes | | 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 | 1575 ⬡ | 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. | | `/work` | 1h | 1575 ⬡ | 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 | 1040 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. | | `/beg` | 5min | 1040 ⬡ | Hiirematt reduces cooldown to 3min. Mehaaniline klaviatuur multiplies earnings ×2. |
| `/crime` | 2h | 200500 ⬡ | 60% success rate (75% with Cat6 kaabel). +30% earnings with Mikrofon on win. Fail = fine + 30min jail. Gaming tool skips jail on fail. | | `/crime` | 2h | 200500 ⬡ | 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 | | Hiirematt | 600 ⬡ | `/beg` cooldown 5min → 3min |
| Red Bull | 800 ⬡ | `/work` has 30% chance to earn ×3 | | 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. | | 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 | | LAN pilet | 1 200 ⬡ | `/daily` reward ×2 |
| Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) | | Bot Farm | 1 500 ⬡ | `/daily` adds 5% interest on balance (capped at 500 ⬡/day) |

11
bot.py
View File

@@ -688,8 +688,10 @@ register_prestige_commands(
def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]: def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
"""Parse an amount string; 'all' resolves to the user's full balance. """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). Accepts plain non-negative integers and valid thousand-separated numbers
Rejects decimals and ambiguous inputs like 1,1 or 1.5. (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.""" Returns (amount, None) on success or (None, error_msg) on failure."""
v = value.strip() v = value.strip()
if v.lower() == "all": 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): if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v):
v = re.sub(r'[,. ]', '', v) v = re.sub(r'[,. ]', '', v)
try: try:
return int(v), None amount = int(v)
except ValueError: except ValueError:
return None, S.ERR["invalid_amount"] return None, S.ERR["invalid_amount"]
if amount < 0:
return None, S.ERR["invalid_amount"]
return amount, None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -926,13 +926,17 @@ def register_economy_games_commands(
embed = self._cur_embed(game_over=True, hand_results=hand_results) embed = self._cur_embed(game_over=True, hand_results=hand_results)
embed.title = S.TITLE[title_key] embed.title = S.TITLE[title_key]
embed.color = color 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( embed.add_field(
name=S.BJ["result_field"], name=S.BJ["result_field"],
value=result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"])), value=result_line,
inline=False, inline=False,
) )
await self.message.edit(embed=embed, view=self) 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))) asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested)))
async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None: async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None:
@@ -1150,32 +1154,37 @@ def register_economy_games_commands(
await asyncio.sleep(_BJ_DEAL_DELAY) await asyncio.sleep(_BJ_DEAL_DELAY)
if _bj_is_blackjack(dealer_hand): if _bj_is_blackjack(dealer_hand):
push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet) 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( embed = _bj_embed(
player_hand, player_hand,
dealer_hand, dealer_hand,
S.TITLE["blackjack_push"], S.TITLE["blackjack_push"],
0x99AAB5, 0x99AAB5,
hide_dealer=False, hide_dealer=False,
result_field=( result_field=(S.BJ["result_field"], push_line),
S.BJ["result_field"],
S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"])),
),
) )
else: else:
payout = bet + int(bet * 1.5) payout = bet + int(bet * 1.5)
bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet) 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( embed = _bj_embed(
player_hand, player_hand,
dealer_hand, dealer_hand,
S.TITLE["blackjack_bj"], S.TITLE["blackjack_bj"],
0xF4C430, 0xF4C430,
hide_dealer=False, hide_dealer=False,
result_field=( result_field=(S.BJ["result_field"], bj_line),
S.BJ["result_field"],
f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"])),
),
) )
asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet))) if bj_res.get("ok"):
asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet)))
active_games.discard(interaction.user.id) active_games.discard(interaction.user.id)
await msg.edit(embed=embed) await msg.edit(embed=embed)
return return

View File

@@ -29,10 +29,15 @@ def register_economy_profile_commands(
pct = progress / needed if needed > 0 else 1.0 pct = progress / needed if needed > 0 else 1.0
filled = int(pct * 12) filled = int(pct * 12)
bar = "" * filled + "" * (12 - filled) bar = "" * filled + "" * (12 - filled)
embed = discord.Embed( badge = economy.vanity_badge(data)
title=S.PROFILE_UI["main_title"].format(name=target.display_name), title = (
color=0xF4C430, 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_balance"], value=coin(data.get("balance", 0)), inline=True)
embed.add_field( embed.add_field(
name=S.PROFILE_UI["f_level"], name=S.PROFILE_UI["f_level"],

View File

@@ -224,6 +224,10 @@ def register_economy_support_commands(
cons = res["consumable"] cons = res["consumable"]
if res["instant"]: 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"])) desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"]))
else: else:
key = "bought_extended" if res["extended"] else "bought_buff" 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) 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): class RemindersSelect(discord.ui.Select):
def __init__(self, user_id: int, current: list[str]): def __init__(self, user_id: int, current: list[str]):
self.user_id = user_id self.user_id = user_id

View File

@@ -19,6 +19,7 @@ from .house import _credit_house, _house_record_id
from .levels import * from .levels import *
from .shop import * from .shop import *
from .consumables import * from .consumables import *
from .vanity import *
from .fishing import * from .fishing import *
from .quests import * from .quests import *
from .quests import _ensure_quests, _pick_quests, _quest_view 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.) from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
admin, consumables, fishing, gambling, heist, house, income, jail, 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 ..emoji import EMOJI as E
from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user 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): class Consumable(TypedDict):
name: str name: str
@@ -66,6 +77,11 @@ _BUFF_MULT: dict[str, float] = {"earn": 2.0, "exp": 2.0}
# Cooldown timestamps an instant "kohv" clears. # Cooldown timestamps an instant "kohv" clears.
_COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish") _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) # 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 ..pb_client import DatabaseError
from ..emoji import EMOJI as E 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 from .house import _credit_house
@@ -254,8 +254,15 @@ async def do_blackjack_bet(user_id: int, bet: int) -> dict:
@_locked_by(0) @_locked_by(0)
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict: 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.
user = await get_user(user_id)
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"] += payout
user["balance"] = max(0, user["balance"]) user["balance"] = max(0, user["balance"])
user["total_wagered"] = user.get("total_wagered", 0) + total_invested 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: elif net < 0:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net) user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
user["biggest_loss"] = max(user.get("biggest_loss", 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 house_gain = total_invested - payout
if house_gain > 0: if house_gain > 0:
await _credit_house(house_gain) await _credit_house(house_gain)

View File

@@ -8,7 +8,7 @@ from .. import pb_client
from ..pb_client import DatabaseError from ..pb_client import DatabaseError
from . import house from . import house
from .store import HEIST_JAIL, _commit, _is_jailed, _now, _txn, _user_lock, get_user 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: if success and payout_each > 0:
await _refund_house_safe(payout_each, "heist_win_compensate", uid) await _refund_house_safe(payout_each, "heist_win_compensate", uid)
elif not success and fine_credited: 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} 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: async def _debit_house_safe(amount: int, context: str, uid: int) -> None:
"""Best-effort atomic debit of `amount` from the house (compensates a failed """Best-effort atomic debit of `amount` from the house. Compensates a fine
user fine). Logs critical if it fails.""" 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: if HOUSE_ID is None or amount <= 0:
return return
try: try:

View File

@@ -107,6 +107,8 @@ class UserData(TypedDict, total=False):
items: list[str] items: list[str]
item_uses: dict # {item_id: remaining_uses} for consumables item_uses: dict # {item_id: remaining_uses} for consumables
active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts 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 jailed_until: str | None # ISO datetime or None
jailbreak_used: bool jailbreak_used: bool
reminders: list[str] # command names user wants DM reminders for reminders: list[str] # command names user wants DM reminders for
@@ -161,6 +163,8 @@ def _default_user() -> UserData:
"items": [], "items": [],
"item_uses": {}, "item_uses": {},
"active_buffs": {}, "active_buffs": {},
"vanity_owned": [],
"vanity_active": None,
"jailed_until": None, "jailed_until": None,
"jailbreak_used": False, "jailbreak_used": False,
"reminders": ["daily", "work", "beg", "crime", "rob"], "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"]}

View File

@@ -116,7 +116,7 @@ All economy state is stored in **PocketBase** (`economy_users` collection). `cor
| Command | Cooldown | Base Earn | Notes | | 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 | | `/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 | | `/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 | | `/crime` | 2h | 200-500⬡ win | 60% success (75% w/ cat6), +30% w/ mikrofon; fail = fine + jail |

View File

@@ -34,10 +34,12 @@ from core import economy # noqa: E402
PB_URL = config.PB_URL PB_URL = config.PB_URL
COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY}) 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 = { _TEXT_FIELDS = {
"last_daily", "last_work", "last_beg", "last_crime", "last_rob", "last_daily", "last_work", "last_beg", "last_crime", "last_rob",
"last_heist", "last_fish", "last_streak_date", "jailed_until", "last_heist", "last_fish", "last_streak_date", "jailed_until",
"vanity_active",
} }

View File

@@ -58,6 +58,7 @@ from .economy import (
ITEM_DESCRIPTIONS, ITEM_DESCRIPTIONS,
CONSUMABLES_UI, CONSUMABLES_UI,
CONSUMABLE_DESCRIPTIONS, CONSUMABLE_DESCRIPTIONS,
VANITY_UI,
JAILED_UI, JAILED_UI,
SHOP_BTN, SHOP_BTN,
DAILY_UI, DAILY_UI,
@@ -150,6 +151,7 @@ __all__ = [
'ITEM_DESCRIPTIONS', 'ITEM_DESCRIPTIONS',
'CONSUMABLES_UI', 'CONSUMABLES_UI',
'CONSUMABLE_DESCRIPTIONS', 'CONSUMABLE_DESCRIPTIONS',
'VANITY_UI',
'JAILED_UI', 'JAILED_UI',
'SHOP_BTN', 'SHOP_BTN',
'DAILY_UI', 'DAILY_UI',

View File

@@ -74,6 +74,7 @@ CMD: dict[str, str] = {
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi", "patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad", "quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)", "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)", "give_summa": "Kui palju annad? ('all' = kogu saldo)",
"buy_ese": "Eseme nimi (vaata /shop)", "buy_ese": "Eseme nimi (vaata /shop)",
"consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)", "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_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)",
"rps_vastane": "Väljakutse teisele mängijale (PvP)", "rps_vastane": "Väljakutse teisele mängijale (PvP)",
"slots_panus": "Panus TipiCOINides ('all' = kogu saldo)", "slots_panus": "Panus TipiCOINides ('all' = kogu saldo)",

View File

@@ -175,6 +175,7 @@ ERR: dict[str, str] = {
"guild_only": "Seda käsku saab kasutada ainult serveris.", "guild_only": "Seda käsku saab kasutada ainult serveris.",
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```", "sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
"gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.", "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!",
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -17,6 +17,7 @@ __all__ = [
'ITEM_DESCRIPTIONS', 'ITEM_DESCRIPTIONS',
'CONSUMABLES_UI', 'CONSUMABLES_UI',
'CONSUMABLE_DESCRIPTIONS', 'CONSUMABLE_DESCRIPTIONS',
'VANITY_UI',
'JAILED_UI', 'JAILED_UI',
'SHOP_BTN', 'SHOP_BTN',
'DAILY_UI', 'DAILY_UI',
@@ -240,6 +241,22 @@ CONSUMABLES_UI: dict[str, str] = {
"bought_instant": "☕ Kõik ooteajad nullitud!\nUus saldo: {balance}", "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] = { JAILED_UI: dict[str, str] = {
"title": "🔒 Praegu vanglas", "title": "🔒 Praegu vanglas",
"empty": "Kõik on vabad! Vanglas pole kedagi.", "empty": "Kõik on vabad! Vanglas pole kedagi.",

View File

@@ -100,3 +100,10 @@ class TestEffects:
user = run(economy.get_user(UID)) user = run(economy.get_user(UID))
assert user["last_work"] is None # cooldown wiped -> /work is ready assert user["last_work"] is None # cooldown wiped -> /work is ready
assert economy.store._cooldown_remaining(user, "work") is None 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")

View File

@@ -10,6 +10,7 @@ Covers the two pure-logic fixes:
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from core import economy from core import economy
from core.pb_client import DatabaseError
from conftest import run from conftest import run
@@ -89,3 +90,26 @@ class TestBailIdempotency:
self._jail(fake_pb, now, balance=1000) self._jail(fake_pb, now, balance=1000)
res = run(economy.do_bail(UID)) res = run(economy.do_bail(UID))
assert res["ok"] and res["fine"] >= economy.MIN_BAIL 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
View 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