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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
104 lines
4.7 KiB
Python
104 lines
4.7 KiB
Python
"""Bank heist: group robbery of the house."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
|
|
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, _debit_house_safe
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /heist
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def do_heist_check(user_id: int) -> dict:
|
|
"""Check whether a user is eligible to join a heist."""
|
|
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 jail := _is_jailed(user):
|
|
return {"ok": False, "reason": "jailed", "remaining": jail}
|
|
return {"ok": True}
|
|
|
|
|
|
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
|
"""Apply heist outcome to all participants. On win, steals from house.
|
|
|
|
Per-user commit failures attempt to compensate the house so the economy
|
|
stays balanced. If compensation also fails, a CRITICAL log is emitted.
|
|
"""
|
|
now = _now()
|
|
payout_each = 0
|
|
failed_users: list[int] = []
|
|
|
|
if success and house.HOUSE_ID is not None:
|
|
# NB: use a distinct local name - assigning to `house` here would shadow
|
|
# the imported module for the whole function and break `house.HOUSE_ID`.
|
|
try:
|
|
house_rec = await get_user(house.HOUSE_ID)
|
|
pct = random.uniform(0.20, 0.55)
|
|
# Never promise more than the house actually holds: the desired pot
|
|
# is capped at the current balance, and each share is floored, so the
|
|
# amount debited equals the amount paid out (no minting, no leak).
|
|
pot = min(max(300, int(house_rec["balance"] * pct)), house_rec["balance"])
|
|
pot = max(0, pot)
|
|
payout_each = pot // len(user_ids)
|
|
debit = payout_each * len(user_ids)
|
|
# Atomic decrement instead of a full record commit, so concurrent
|
|
# _credit_house increments aren't lost.
|
|
if debit > 0:
|
|
await pb_client.update_record(house_rec["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item]
|
|
except DatabaseError:
|
|
return {"ok": False, "reason": "db_error"}
|
|
_txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house_rec["balance"] - debit)
|
|
|
|
for uid in user_ids:
|
|
async with _user_lock(uid):
|
|
try:
|
|
user = await get_user(uid)
|
|
except DatabaseError:
|
|
failed_users.append(uid)
|
|
if success and payout_each > 0:
|
|
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
|
continue
|
|
user["last_heist"] = now.isoformat()
|
|
user["heists_joined"] = user.get("heists_joined", 0) + 1
|
|
fine_credited = False
|
|
if success:
|
|
user["balance"] += payout_each
|
|
user["heists_won"] = user.get("heists_won", 0) + 1
|
|
user["lifetime_earned"] = user.get("lifetime_earned", 0) + payout_each
|
|
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
|
_txn("HEIST_WIN", user=uid, change=f"+{payout_each}", bal=user["balance"])
|
|
else:
|
|
fine = max(150, min(1000, int(user["balance"] * 0.15)))
|
|
user["balance"] = max(0, user["balance"] - fine)
|
|
user["jailed_until"] = (now + HEIST_JAIL).isoformat()
|
|
user["jailbreak_used"] = False
|
|
user["times_jailed"] = user.get("times_jailed", 0) + 1
|
|
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
|
|
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
|
|
if fine > 0:
|
|
try:
|
|
await _credit_house(fine)
|
|
fine_credited = True
|
|
except DatabaseError:
|
|
pass # user commit will still be attempted; if both fail, no economy effect
|
|
try:
|
|
await _commit(uid, user)
|
|
except DatabaseError:
|
|
failed_users.append(uid)
|
|
if success and payout_each > 0:
|
|
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
|
elif not success and fine_credited:
|
|
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}
|