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:
Rene Arumetsa
2026-09-04 02:53:14 +03:00
parent 417617175e
commit 42bbdbd93d
12 changed files with 264 additions and 10 deletions

View File

@@ -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. |

View File

@@ -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(

View File

@@ -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

View File

@@ -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,
) )

View File

@@ -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
View 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"]}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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',

View File

@@ -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"),

View File

@@ -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
View 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