From 037628d24fadd8337d0122f70d687b6adc73b710 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:09:25 +0300 Subject: [PATCH] feat(economy): add vanity shop and harden economy money-safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- README.md | 4 +- bot.py | 11 +++- commands/economy_games_commands.py | 31 +++++---- commands/economy_profile_commands.py | 11 +++- commands/economy_support_commands.py | 73 +++++++++++++++++++++ core/economy/__init__.py | 3 +- core/economy/consumables.py | 16 +++++ core/economy/gambling.py | 25 +++++-- core/economy/heist.py | 4 +- core/economy/house.py | 7 +- core/economy/store.py | 4 ++ core/economy/vanity.py | 98 ++++++++++++++++++++++++++++ docs/DEV_NOTES.md | 2 +- scripts/sync_pb_schema.py | 4 +- strings/__init__.py | 2 + strings/commands.py | 2 + strings/common.py | 1 + strings/economy.py | 17 +++++ tests/test_consumables.py | 7 ++ tests/test_money_safety_fixes.py | 24 +++++++ tests/test_vanity.py | 78 ++++++++++++++++++++++ 21 files changed, 393 insertions(+), 31 deletions(-) create mode 100644 core/economy/vanity.py create mode 100644 tests/test_vanity.py diff --git a/README.md b/README.md index f400608..9c07cbe 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/bot.py b/bot.py index b0f8a7e..932eee8 100644 --- a/bot.py +++ b/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 # --------------------------------------------------------------------------- diff --git a/commands/economy_games_commands.py b/commands/economy_games_commands.py index e798420..3f0dbfb 100644 --- a/commands/economy_games_commands.py +++ b/commands/economy_games_commands.py @@ -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 diff --git a/commands/economy_profile_commands.py b/commands/economy_profile_commands.py index 976f561..c47ec9d 100644 --- a/commands/economy_profile_commands.py +++ b/commands/economy_profile_commands.py @@ -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"], diff --git a/commands/economy_support_commands.py b/commands/economy_support_commands.py index f003096..76778bd 100644 --- a/commands/economy_support_commands.py +++ b/commands/economy_support_commands.py @@ -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 diff --git a/core/economy/__init__.py b/core/economy/__init__.py index 52bb322..be93144 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -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, ) diff --git a/core/economy/consumables.py b/core/economy/consumables.py index 09f5aae..74e7150 100644 --- a/core/economy/consumables.py +++ b/core/economy/consumables.py @@ -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) diff --git a/core/economy/gambling.py b/core/economy/gambling.py index b0dc6f3..c89a72d 100644 --- a/core/economy/gambling.py +++ b/core/economy/gambling.py @@ -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) diff --git a/core/economy/heist.py b/core/economy/heist.py index ff0f75e..2651477 100644 --- a/core/economy/heist.py +++ b/core/economy/heist.py @@ -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} diff --git a/core/economy/house.py b/core/economy/house.py index 4ee14ba..9a7bd46 100644 --- a/core/economy/house.py +++ b/core/economy/house.py @@ -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: diff --git a/core/economy/store.py b/core/economy/store.py index 5f4d43b..7742334 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -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"], diff --git a/core/economy/vanity.py b/core/economy/vanity.py new file mode 100644 index 0000000..41c79d6 --- /dev/null +++ b/core/economy/vanity.py @@ -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"]} diff --git a/docs/DEV_NOTES.md b/docs/DEV_NOTES.md index 8fa1796..c00a2cb 100644 --- a/docs/DEV_NOTES.md +++ b/docs/DEV_NOTES.md @@ -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 | diff --git a/scripts/sync_pb_schema.py b/scripts/sync_pb_schema.py index 39e7d29..6e31cdf 100644 --- a/scripts/sync_pb_schema.py +++ b/scripts/sync_pb_schema.py @@ -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", } diff --git a/strings/__init__.py b/strings/__init__.py index 4c65c3a..8bcac59 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -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', diff --git a/strings/commands.py b/strings/commands.py index e3a9a28..bbd176e 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -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)", diff --git a/strings/common.py b/strings/common.py index 965d007..99b3b5f 100644 --- a/strings/common.py +++ b/strings/common.py @@ -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!", } # --------------------------------------------------------------------------- diff --git a/strings/economy.py b/strings/economy.py index f8e4557..14e18e8 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -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 · „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.", diff --git a/tests/test_consumables.py b/tests/test_consumables.py index 17047d2..c078f6b 100644 --- a/tests/test_consumables.py +++ b/tests/test_consumables.py @@ -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") diff --git a/tests/test_money_safety_fixes.py b/tests/test_money_safety_fixes.py index 39ccdfd..be28a63 100644 --- a/tests/test_money_safety_fixes.py +++ b/tests/test_money_safety_fixes.py @@ -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 diff --git a/tests/test_vanity.py b/tests/test_vanity.py new file mode 100644 index 0000000..fdd010c --- /dev/null +++ b/tests/test_vanity.py @@ -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