forked from sass/tipibot
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
This commit is contained in:
@@ -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. |
|
| `/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. |
|
| `/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. |
|
| `/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. |
|
| `/shop` | Browse all items by tier. Shows owned status, Anticheat charges remaining, and level lock for T2/T3. |
|
||||||
| `/buy <item>` | Purchase an item by name (partial match accepted). |
|
| `/buy <item>` | 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 <amount>` | Move coins from your wallet into the bank. `all` deposits everything liquid. |
|
||||||
|
| `/withdraw <amount>` | 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. |
|
| `/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. |
|
| `/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. |
|
| `/vanity` | Status shop — buy and equip cosmetic badges/titles (shown on `/profile`). Coins are **burned**, not recirculated. No gameplay effect. |
|
||||||
|
|||||||
@@ -292,6 +292,9 @@ def register_economy_profile_commands(
|
|||||||
color=0xF4C430,
|
color=0xF4C430,
|
||||||
)
|
)
|
||||||
embed.add_field(name=S.BALANCE_UI["saldo"], value=coin(data["balance"]), inline=True)
|
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)
|
streak = data.get("daily_streak", 0)
|
||||||
if streak:
|
if streak:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
|
|||||||
@@ -363,6 +363,69 @@ def register_economy_support_commands(
|
|||||||
embed.set_footer(text=foot)
|
embed.set_footer(text=foot)
|
||||||
await msg.edit(embed=embed)
|
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):
|
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
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from .store import (
|
|||||||
)
|
)
|
||||||
from .house import *
|
from .house import *
|
||||||
from .house import _credit_house, _house_record_id
|
from .house import _credit_house, _house_record_id
|
||||||
|
from .bank import *
|
||||||
from .levels import *
|
from .levels import *
|
||||||
from .shop import *
|
from .shop import *
|
||||||
from .consumables import *
|
from .consumables import *
|
||||||
@@ -33,6 +34,6 @@ from .heist import *
|
|||||||
from .admin import *
|
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, bank, consumables, fishing, gambling, heist, house, income, jail,
|
||||||
leaderboards, levels, lootbox, prestige, quests, shop, store, vanity,
|
leaderboards, levels, lootbox, prestige, quests, shop, store, vanity,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
|
|||||||
reset_fields = {
|
reset_fields = {
|
||||||
"exp": 0,
|
"exp": 0,
|
||||||
"balance": 0,
|
"balance": 0,
|
||||||
|
"bank_balance": 0,
|
||||||
"items": [],
|
"items": [],
|
||||||
"item_uses": {},
|
"item_uses": {},
|
||||||
"last_daily": None,
|
"last_daily": None,
|
||||||
|
|||||||
63
core/economy/bank.py
Normal file
63
core/economy/bank.py
Normal file
@@ -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"]}
|
||||||
@@ -7,11 +7,17 @@ from . import house
|
|||||||
from .levels import get_level
|
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]]:
|
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()
|
records = await pb_client.list_all_records()
|
||||||
result = sorted(
|
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],
|
key=lambda x: x[1],
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
@@ -97,8 +103,8 @@ async def get_all_leaderboards() -> dict[str, list[tuple]]:
|
|||||||
return sorted(users, key=keyfn, reverse=True)
|
return sorted(users, key=keyfn, reverse=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"coins": [(r["user_id"], r.get("balance", 0))
|
"coins": [(r["user_id"], _net_worth(r))
|
||||||
for r in desc(lambda r: r.get("balance", 0))],
|
for r in desc(_net_worth)],
|
||||||
"exp": [(r["user_id"], r.get("exp", 0), get_level(r.get("exp", 0)))
|
"exp": [(r["user_id"], r.get("exp", 0), get_level(r.get("exp", 0)))
|
||||||
for r in desc(lambda r: 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))
|
"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")
|
uid = r.get("user_id")
|
||||||
if not uid:
|
if not uid:
|
||||||
continue
|
continue
|
||||||
bal = r.get("balance", 0) or 0
|
# Banked coins are still part of the money supply.
|
||||||
total += bal
|
worth = (r.get("balance", 0) or 0) + (r.get("bank_balance", 0) or 0)
|
||||||
|
total += worth
|
||||||
if uid == house_id:
|
if uid == house_id:
|
||||||
house_balance = bal
|
house_balance = worth
|
||||||
else:
|
else:
|
||||||
player_count += 1
|
player_count += 1
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ HEIST_JAIL = timedelta(hours=1, minutes=30)
|
|||||||
# User schema
|
# User schema
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class UserData(TypedDict, total=False):
|
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)
|
exp: int # lifetime EXP (resets each season)
|
||||||
last_daily: str | None
|
last_daily: str | None
|
||||||
last_work: str | None
|
last_work: str | None
|
||||||
@@ -177,6 +178,7 @@ class UserData(TypedDict, total=False):
|
|||||||
def _default_user() -> UserData:
|
def _default_user() -> UserData:
|
||||||
return {
|
return {
|
||||||
"balance": 0,
|
"balance": 0,
|
||||||
|
"bank_balance": 0,
|
||||||
"exp": 0,
|
"exp": 0,
|
||||||
"last_daily": None,
|
"last_daily": None,
|
||||||
"last_work": None,
|
"last_work": None,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from .economy import (
|
|||||||
CONSUMABLE_DESCRIPTIONS,
|
CONSUMABLE_DESCRIPTIONS,
|
||||||
VANITY_UI,
|
VANITY_UI,
|
||||||
LOOTBOX_UI,
|
LOOTBOX_UI,
|
||||||
|
BANK_UI,
|
||||||
JAILED_UI,
|
JAILED_UI,
|
||||||
SHOP_BTN,
|
SHOP_BTN,
|
||||||
DAILY_UI,
|
DAILY_UI,
|
||||||
@@ -154,6 +155,7 @@ __all__ = [
|
|||||||
'CONSUMABLE_DESCRIPTIONS',
|
'CONSUMABLE_DESCRIPTIONS',
|
||||||
'VANITY_UI',
|
'VANITY_UI',
|
||||||
'LOOTBOX_UI',
|
'LOOTBOX_UI',
|
||||||
|
'BANK_UI',
|
||||||
'JAILED_UI',
|
'JAILED_UI',
|
||||||
'SHOP_BTN',
|
'SHOP_BTN',
|
||||||
'DAILY_UI',
|
'DAILY_UI',
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ CMD: dict[str, str] = {
|
|||||||
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
|
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
|
||||||
"vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid",
|
"vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid",
|
||||||
"lootbox": "Ava õnnekast - juhuslik auhind müntide või boonuse näol",
|
"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_kasutaja": "Kellele annad?",
|
||||||
"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)",
|
||||||
|
"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)",
|
"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)",
|
"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)",
|
||||||
@@ -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)."),
|
("/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."),
|
("/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 <amount>", "Anna TipiCOINe teisele mängijale"),
|
("/give @user <amount>", "Anna TipiCOINe teisele mängijale"),
|
||||||
|
("/bank", "Vaata oma panka. Panka pandud mündid on röövikindlad (/rob ja /heist ei puuduta)."),
|
||||||
|
("/deposit <amount>", "Pane münte panka (röövikindel hoius, ei teeni intressi)."),
|
||||||
|
("/withdraw <amount>", "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)."),
|
("/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?"),
|
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
|
||||||
("/shop", "Sirvi TipiBOTi poodi"),
|
("/shop", "Sirvi TipiBOTi poodi"),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ __all__ = [
|
|||||||
'CONSUMABLE_DESCRIPTIONS',
|
'CONSUMABLE_DESCRIPTIONS',
|
||||||
'VANITY_UI',
|
'VANITY_UI',
|
||||||
'LOOTBOX_UI',
|
'LOOTBOX_UI',
|
||||||
|
'BANK_UI',
|
||||||
'JAILED_UI',
|
'JAILED_UI',
|
||||||
'SHOP_BTN',
|
'SHOP_BTN',
|
||||||
'DAILY_UI',
|
'DAILY_UI',
|
||||||
@@ -275,6 +276,20 @@ LOOTBOX_UI: dict[str, str] = {
|
|||||||
"foot_buff": "Saldo: {balance}",
|
"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] = {
|
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.",
|
||||||
|
|||||||
86
tests/test_bank.py
Normal file
86
tests/test_bank.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user