Files
tipibot/core/economy/heist.py
Rene Arumetsa 968356f925 Fix money-safety bugs in economy (heist, blackjack, bail)
Multi-agent audit of the money-moving economy modules surfaced three
confirmed correctness bugs. All three are fixed here with regression tests.

heist (core/economy/heist.py):
- `house = await get_user(house.HOUSE_ID)` shadowed the imported `house`
  module for the whole function, so `house.HOUSE_ID` raised UnboundLocalError
  on every successful heist (win payout was entirely dead) and on the
  fail-path compensation branch. Rename the local to `house_rec`.
- Un-shadowing exposed a latent mint: the pot was floored at 300 but the
  house was debited only min(total, balance), so a poor house paid out more
  than it lost. Cap the pot at the balance and debit exactly what is paid
  (house debit == sum of payouts). No mint, no leak.
- Add the missing `_is_jailed` import (do_heist_check referenced it unimported).

blackjack (commands/economy_games_commands.py):
- Button callbacks had no reentrancy guard; discord.py dispatches each click
  as its own task, so double-clicking Stand within the dealer-reveal window
  paid out twice (mint), and double-clicking Double/Split deducted the extra
  bet twice. Add a synchronous `_busy` guard (matching the existing RpsGame
  idiom) on all four callbacks plus a `_resolved` idempotency flag on
  settlement, so a game can only pay out once.

bail (core/economy/jail.py, commands/economy_extra_commands.py):
- do_bail only checked balance, never jail state; a double-click or a stale
  BailView from a re-run /jailbreak charged bail twice, destroying coins (bail
  is a pure sink). Make do_bail a no-op when the user is not jailed, and add a
  UI reentrancy guard + "already free" message.

Tests: 47 passed (4 new regression tests covering heist coin-conservation and
bail idempotency).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 18:31:46 +03:00

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, _refund_user_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 _refund_user_safe(house.HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}