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:
@@ -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,32 +1154,37 @@ 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),
|
||||
)
|
||||
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)
|
||||
await msg.edit(embed=embed)
|
||||
return
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user