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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user