From 42bbdbd93d552a2d725a247d4664904feb37d889 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 02:53:14 +0300 Subject: [PATCH] feat(economy): add /bank vault (rob-proof storage) + net-worth leaderboard A strategic counterpart to /rob: coins moved to the bank are safe from /rob and /heist (which only touch liquid balance) but earn no Bot Farm interest and can't be spent, gambled or given until withdrawn - the deliberate trade-off against keeping coins liquid. - New bank.py (do_deposit/do_withdraw), bank_balance schema field. - /bank (view), /deposit, /withdraw commands ('all' supported), with db_error handling; /balance shows the vault when non-zero. - Coins leaderboard and /status money-supply now count net worth (balance + bank_balance), so banking never hides you from the board or the supply metric. - Season reset wipes bank_balance too (no cross-season wealth hiding). 10 tests: deposit/withdraw math, guards, coin conservation, rob cannot touch the vault, and net-worth accounting on the leaderboard + stats. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- README.md | 5 +- commands/economy_profile_commands.py | 3 + commands/economy_support_commands.py | 63 ++++++++++++++++++++ core/economy/__init__.py | 3 +- core/economy/admin.py | 1 + core/economy/bank.py | 63 ++++++++++++++++++++ core/economy/leaderboards.py | 21 ++++--- core/economy/store.py | 4 +- strings/__init__.py | 2 + strings/commands.py | 8 +++ strings/economy.py | 15 +++++ tests/test_bank.py | 86 ++++++++++++++++++++++++++++ 12 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 core/economy/bank.py create mode 100644 tests/test_bank.py diff --git a/README.md b/README.md index 3b88501..2eb5769 100644 --- a/README.md +++ b/README.md @@ -336,9 +336,12 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e | `/rank [@user]` | EXP total, current level, progress bar to next level, leaderboard rank. | | `/stats [@user]` | Lifetime statistics: economy totals, work/beg counts, gambling records, crime/heist history, social totals, best streak. | | `/cooldowns` | All cooldowns at a glance with live Discord timestamps. Shows jail timer if jailed. | -| `/leaderboard` | Paginated leaderboard with 6 tabs: 🪙 Coins, 📊 EXP, 🏆 Season EXP, 🔥 Prestige, 🎲 Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. | +| `/leaderboard` | Paginated leaderboard with 6 tabs: 🪙 Coins (net worth = wallet + bank), 📊 EXP, 🏆 Season EXP, 🔥 Prestige, 🎲 Wagered, 🎣 Fish caught. House pinned at #0 on coins tab. | | `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. | | `/buy ` | Purchase an item by name (partial match accepted). | +| `/bank` | View your vault. Banked coins are **rob-proof** (`/rob` and `/heist` only touch liquid balance) but earn no Bot Farm interest and can't be spent until withdrawn. | +| `/deposit ` | Move coins from your wallet into the bank. `all` deposits everything liquid. | +| `/withdraw ` | Move coins from the bank back to your wallet. `all` withdraws everything banked. | | `/consumables` | Buy repeatable, expiring boosts (earn ×2, EXP ×2, or an instant cooldown wipe). A recurring coin sink. | | `/lootbox` | Open a mystery box for **1 000 ⬡**. Weighted random reward: coin tiers (usually a small net loss), a 30-min earn/EXP ×2 buff, or a rare jackpot. | | `/vanity` | Status shop — buy and equip cosmetic badges/titles (shown on `/profile`). Coins are **burned**, not recirculated. No gameplay effect. | diff --git a/commands/economy_profile_commands.py b/commands/economy_profile_commands.py index c47ec9d..7a1a256 100644 --- a/commands/economy_profile_commands.py +++ b/commands/economy_profile_commands.py @@ -292,6 +292,9 @@ def register_economy_profile_commands( color=0xF4C430, ) embed.add_field(name=S.BALANCE_UI["saldo"], value=coin(data["balance"]), inline=True) + bank_balance = data.get("bank_balance", 0) + if bank_balance: + embed.add_field(name=S.BANK_UI["f_bank"], value=coin(bank_balance), inline=True) streak = data.get("daily_streak", 0) if streak: embed.add_field( diff --git a/commands/economy_support_commands.py b/commands/economy_support_commands.py index 79157fc..4abfacb 100644 --- a/commands/economy_support_commands.py +++ b/commands/economy_support_commands.py @@ -363,6 +363,69 @@ def register_economy_support_commands( embed.set_footer(text=foot) await msg.edit(embed=embed) + # -- /bank, /deposit, /withdraw ----------------------------------------- + @tree.command(name="bank", description=S.CMD["bank"]) + async def cmd_bank(interaction: discord.Interaction): + data = await economy.get_user(interaction.user.id) + embed = discord.Embed( + title=S.BANK_UI["title"], description=S.BANK_UI["desc"], color=0xF4C430 + ) + embed.add_field(name=S.BANK_UI["f_liquid"], value=coin(data.get("balance", 0)), inline=True) + embed.add_field(name=S.BANK_UI["f_bank"], value=coin(data.get("bank_balance", 0)), inline=True) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @tree.command(name="deposit", description=S.CMD["deposit"]) + @app_commands.describe(summa=S.OPT["deposit_summa"]) + async def cmd_deposit(interaction: discord.Interaction, summa: str): + data = await economy.get_user(interaction.user.id) + amount, err = parse_amount(summa, data.get("balance", 0)) + if err or amount is None: + await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True) + return + if amount <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + res = await economy.do_deposit(interaction.user.id, amount) + if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + elif res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + else: + await interaction.response.send_message(S.BANK_UI["nothing_liquid"], ephemeral=True) + return + await interaction.response.send_message( + S.BANK_UI["deposited"].format( + amount=coin(res["amount"]), balance=coin(res["balance"]), bank=coin(res["bank"]) + ) + ) + + @tree.command(name="withdraw", description=S.CMD["withdraw"]) + @app_commands.describe(summa=S.OPT["withdraw_summa"]) + async def cmd_withdraw(interaction: discord.Interaction, summa: str): + data = await economy.get_user(interaction.user.id) + amount, err = parse_amount(summa, data.get("bank_balance", 0)) + if err or amount is None: + await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True) + return + if amount <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + res = await economy.do_withdraw(interaction.user.id, amount) + if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + elif res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + else: + await interaction.response.send_message(S.BANK_UI["nothing_bank"], ephemeral=True) + return + await interaction.response.send_message( + S.BANK_UI["withdrawn"].format( + amount=coin(res["amount"]), balance=coin(res["balance"]), bank=coin(res["bank"]) + ) + ) + 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 4a89445..1f2a941 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -16,6 +16,7 @@ from .store import ( ) from .house import * from .house import _credit_house, _house_record_id +from .bank import * from .levels import * from .shop import * from .consumables import * @@ -33,6 +34,6 @@ from .heist import * from .admin import * from . import ( # noqa: E402 (submodules addressable as economy.store etc.) - admin, consumables, fishing, gambling, heist, house, income, jail, + admin, bank, consumables, fishing, gambling, heist, house, income, jail, leaderboards, levels, lootbox, prestige, quests, shop, store, vanity, ) diff --git a/core/economy/admin.py b/core/economy/admin.py index ffe3db9..41f6685 100644 --- a/core/economy/admin.py +++ b/core/economy/admin.py @@ -22,6 +22,7 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]: reset_fields = { "exp": 0, "balance": 0, + "bank_balance": 0, "items": [], "item_uses": {}, "last_daily": None, diff --git a/core/economy/bank.py b/core/economy/bank.py new file mode 100644 index 0000000..3e6da2c --- /dev/null +++ b/core/economy/bank.py @@ -0,0 +1,63 @@ +"""Bank vault: rob-proof coin storage. + +Coins moved to the bank are safe from /rob and /heist (those target the liquid +`balance` only), but they earn no interest and cannot be spent, gambled or given +until withdrawn. This is the deliberate trade-off against keeping coins liquid, +where Bot Farm can earn interest but a robber can take a cut. Net worth +(balance + bank_balance) is what the coins leaderboard ranks, so banking never +hides you from the leaderboard. +""" + +from __future__ import annotations + +from ..pb_client import DatabaseError +from .store import _commit, _locked_by, _txn, get_user + +__all__ = ["do_deposit", "do_withdraw"] + + +@_locked_by(0) +async def do_deposit(user_id: int, amount: int) -> dict: + """Move `amount` coins from liquid balance into the bank vault.""" + 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 amount <= 0: + return {"ok": False, "reason": "invalid"} + if user["balance"] < amount: + return {"ok": False, "reason": "insufficient", "balance": user["balance"]} + user["balance"] -= amount + user["bank_balance"] = user.get("bank_balance", 0) + amount + try: + await _commit(user_id, user) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + _txn("BANK_DEPOSIT", user=user_id, amount=amount, bal=user["balance"], bank=user["bank_balance"]) + return {"ok": True, "amount": amount, "balance": user["balance"], "bank": user["bank_balance"]} + + +@_locked_by(0) +async def do_withdraw(user_id: int, amount: int) -> dict: + """Move `amount` coins from the bank vault back to liquid balance.""" + 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 amount <= 0: + return {"ok": False, "reason": "invalid"} + bank = user.get("bank_balance", 0) + if bank < amount: + return {"ok": False, "reason": "insufficient", "bank": bank} + user["bank_balance"] = bank - amount + user["balance"] += amount + try: + await _commit(user_id, user) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + _txn("BANK_WITHDRAW", user=user_id, amount=amount, bal=user["balance"], bank=user["bank_balance"]) + return {"ok": True, "amount": amount, "balance": user["balance"], "bank": user["bank_balance"]} diff --git a/core/economy/leaderboards.py b/core/economy/leaderboards.py index 7e31d34..9d39e52 100644 --- a/core/economy/leaderboards.py +++ b/core/economy/leaderboards.py @@ -7,11 +7,17 @@ from . import house from .levels import get_level +def _net_worth(r: dict) -> int: + """Coins that count toward wealth: liquid balance + banked vault.""" + return (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0) + + async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]: - """Return top_n (user_id_str, balance) pairs sorted descending.""" + """Return top_n (user_id_str, net_worth) pairs sorted descending. + Net worth = balance + bank_balance, so banking coins does not hide them.""" records = await pb_client.list_all_records() result = sorted( - ((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")), + ((r["user_id"], _net_worth(r)) for r in records if r.get("user_id")), key=lambda x: x[1], reverse=True, ) @@ -97,8 +103,8 @@ async def get_all_leaderboards() -> dict[str, list[tuple]]: return sorted(users, key=keyfn, reverse=True) return { - "coins": [(r["user_id"], r.get("balance", 0)) - for r in desc(lambda r: r.get("balance", 0))], + "coins": [(r["user_id"], _net_worth(r)) + for r in desc(_net_worth)], "exp": [(r["user_id"], r.get("exp", 0), get_level(r.get("exp", 0))) for r in desc(lambda r: r.get("exp", 0))], "season": [(r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 0)) @@ -125,10 +131,11 @@ async def get_economy_stats() -> dict[str, int]: uid = r.get("user_id") if not uid: continue - bal = r.get("balance", 0) or 0 - total += bal + # Banked coins are still part of the money supply. + worth = (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0) + total += worth if uid == house_id: - house_balance = bal + house_balance = worth else: player_count += 1 return { diff --git a/core/economy/store.py b/core/economy/store.py index efdf7ae..10a0804 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -114,7 +114,8 @@ HEIST_JAIL = timedelta(hours=1, minutes=30) # User schema # --------------------------------------------------------------------------- class UserData(TypedDict, total=False): - balance: int + balance: int # liquid coins - spendable, gamblable, robbable + bank_balance: int # vaulted coins - safe from /rob and /heist, not spendable until withdrawn exp: int # lifetime EXP (resets each season) last_daily: str | None last_work: str | None @@ -177,6 +178,7 @@ class UserData(TypedDict, total=False): def _default_user() -> UserData: return { "balance": 0, + "bank_balance": 0, "exp": 0, "last_daily": None, "last_work": None, diff --git a/strings/__init__.py b/strings/__init__.py index 2bb5696..0e4ce64 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -60,6 +60,7 @@ from .economy import ( CONSUMABLE_DESCRIPTIONS, VANITY_UI, LOOTBOX_UI, + BANK_UI, JAILED_UI, SHOP_BTN, DAILY_UI, @@ -154,6 +155,7 @@ __all__ = [ 'CONSUMABLE_DESCRIPTIONS', 'VANITY_UI', 'LOOTBOX_UI', + 'BANK_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', diff --git a/strings/commands.py b/strings/commands.py index ad82600..227dbb3 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -76,6 +76,9 @@ CMD: dict[str, str] = { "consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)", "vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid", "lootbox": "Ava õnnekast - juhuslik auhind müntide või boonuse näol", + "bank": "Vaata oma panka - röövikindel hoius", + "deposit": "Pane münte panka (röövikindel, aga ei teeni intressi)", + "withdraw": "Võta münte pangast rahakotti", } # --------------------------------------------------------------------------- @@ -93,6 +96,8 @@ OPT: dict[str, str] = { "give_kasutaja": "Kellele annad?", "give_summa": "Kui palju annad? ('all' = kogu saldo)", "buy_ese": "Eseme nimi (vaata /shop)", + "deposit_summa": "Kui palju panna panka? ('all' = kogu vaba raha)", + "withdraw_summa": "Kui palju pangast välja võtta? ('all' = kogu pangas)", "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)", @@ -150,6 +155,9 @@ HELP_CATEGORIES: dict[str, dict] = { ("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mängijat, max 8. 5 min ühinemisaeg. Õnnestumisel jagatakse saak võrdselt - ebaõnnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."), ("/jailbreak", "Proovi vanglas olles täringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. Ebaõnnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⬡) või jää vanglasse kuni aja lõpuni."), ("/give @user ", "Anna TipiCOINe teisele mängijale"), + ("/bank", "Vaata oma panka. Panka pandud mündid on röövikindlad (/rob ja /heist ei puuduta)."), + ("/deposit ", "Pane münte panka (röövikindel hoius, ei teeni intressi)."), + ("/withdraw ", "Võta münte pangast rahakotti."), ("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."), ("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"), ("/shop", "Sirvi TipiBOTi poodi"), diff --git a/strings/economy.py b/strings/economy.py index fadc95d..76be096 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -19,6 +19,7 @@ __all__ = [ 'CONSUMABLE_DESCRIPTIONS', 'VANITY_UI', 'LOOTBOX_UI', + 'BANK_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', @@ -275,6 +276,20 @@ LOOTBOX_UI: dict[str, str] = { "foot_buff": "Saldo: {balance}", } +# --------------------------------------------------------------------------- +# Bank vault (rob-proof storage; liquid vs banked coins) +# --------------------------------------------------------------------------- +BANK_UI: dict[str, str] = { + "title": "🏦 TipiPANK", + "desc": "Panka pandud mündid on **röövikindlad** (`/rob` ja `/heist` neid ei puuduta), aga neid ei saa kulutada ega panustada enne väljavõtmist ega teeni Botikoopa intressi.", + "f_liquid": "💵 Rahakotis (vaba)", + "f_bank": "🏦 Pangas (kaitstud)", + "deposited": "🏦 Panid **{amount}** panka.\n💵 Rahakotis: {balance} · 🏦 Pangas: {bank}", + "withdrawn": "💵 Võtsid **{amount}** pangast välja.\n💵 Rahakotis: {balance} · 🏦 Pangas: {bank}", + "nothing_liquid": "❌ Sul pole nii palju vaba raha rahakotis.", + "nothing_bank": "❌ Sul pole nii palju raha pangas.", +} + JAILED_UI: dict[str, str] = { "title": "🔒 Praegu vanglas", "empty": "Kõik on vabad! Vanglas pole kedagi.", diff --git a/tests/test_bank.py b/tests/test_bank.py new file mode 100644 index 0000000..6ccc9f6 --- /dev/null +++ b/tests/test_bank.py @@ -0,0 +1,86 @@ +"""Tests for the bank vault: rob-proof storage and net-worth accounting.""" + +from core import economy + +from conftest import run + +UID = 6161 +ROBBER = 6162 + + +def _fund(fake_pb, uid: int, balance: int, bank: int = 0) -> None: + run(economy.get_user(uid)) + rec = fake_pb.record_for(uid) + rec["balance"] = balance + rec["bank_balance"] = bank + + +class TestDepositWithdraw: + def test_deposit_moves_liquid_to_bank(self, fake_pb): + _fund(fake_pb, UID, 1000) + res = run(economy.do_deposit(UID, 400)) + assert res["ok"] and res["balance"] == 600 and res["bank"] == 400 + rec = fake_pb.record_for(UID) + assert rec["balance"] == 600 and rec["bank_balance"] == 400 + + def test_withdraw_moves_bank_to_liquid(self, fake_pb): + _fund(fake_pb, UID, 100, bank=500) + res = run(economy.do_withdraw(UID, 300)) + assert res["ok"] and res["balance"] == 400 and res["bank"] == 200 + + def test_deposit_more_than_liquid_rejected(self, fake_pb): + _fund(fake_pb, UID, 100) + res = run(economy.do_deposit(UID, 500)) + assert not res["ok"] and res["reason"] == "insufficient" + assert fake_pb.record_for(UID)["balance"] == 100 # unchanged + + def test_withdraw_more_than_bank_rejected(self, fake_pb): + _fund(fake_pb, UID, 0, bank=100) + res = run(economy.do_withdraw(UID, 500)) + assert not res["ok"] and res["reason"] == "insufficient" + assert fake_pb.record_for(UID)["bank_balance"] == 100 + + def test_nonpositive_rejected(self, fake_pb): + _fund(fake_pb, UID, 1000, bank=1000) + assert run(economy.do_deposit(UID, 0))["reason"] == "invalid" + assert run(economy.do_withdraw(UID, -5))["reason"] == "invalid" + + def test_banned_rejected(self, fake_pb): + _fund(fake_pb, UID, 1000) + fake_pb.record_for(UID)["eco_banned"] = True + assert run(economy.do_deposit(UID, 100))["reason"] == "banned" + + def test_round_trip_conserves_coins(self, fake_pb): + _fund(fake_pb, UID, 1000) + run(economy.do_deposit(UID, 700)) + run(economy.do_withdraw(UID, 700)) + rec = fake_pb.record_for(UID) + assert rec["balance"] == 1000 and rec["bank_balance"] == 0 + + +class TestRobProof: + def test_rob_cannot_touch_banked_coins(self, fake_pb, monkeypatch): + # Target keeps everything banked, only a little liquid (< rob threshold). + _fund(fake_pb, UID, 50, bank=100_000) + _fund(fake_pb, ROBBER, 1000) + # Target has < 100 liquid, so a rob is rejected as "broke" - the vault is + # invisible to /rob (which only reads balance). + res = run(economy.do_rob(ROBBER, UID)) + assert not res["ok"] and res["reason"] == "broke" + assert fake_pb.record_for(UID)["bank_balance"] == 100_000 # untouched + + +class TestNetWorth: + def test_leaderboard_counts_bank(self, fake_pb): + _fund(fake_pb, UID, 100, bank=900) # net worth 1000 + _fund(fake_pb, ROBBER, 500, bank=0) # net worth 500 + board = dict((uid, worth) for uid, worth in run(economy.get_leaderboard(top_n=None))) + assert board[str(UID)] == 1000 + assert board[str(ROBBER)] == 500 + + def test_economy_stats_counts_bank(self, fake_pb, monkeypatch): + monkeypatch.setattr(economy.house, "HOUSE_ID", None) + _fund(fake_pb, UID, 100, bank=900) + stats = run(economy.get_economy_stats()) + assert stats["total_coins"] == 1000 + assert stats["player_coins"] == 1000