Files
tipibot/core/economy/jail.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

86 lines
3.3 KiB
Python

"""Jail, jailbreak and bail."""
from __future__ import annotations
import random
from datetime import timedelta
from .store import (
_commit, _is_jailed, _locked_by, _now, _txn, get_all_users_raw, get_user,
)
@_locked_by(0)
async def do_spam_jail(user_id: int) -> None:
"""Jail a user for 30 minutes due to suspected automated command spam."""
user = await get_user(user_id)
user["jailed_until"] = (_now() + timedelta(minutes=30)).isoformat()
user["jailbreak_used"] = False
user["times_jailed"] = user.get("times_jailed", 0) + 1
await _commit(user_id, user)
_txn("SPAM_JAIL", user=user_id, until=user["jailed_until"])
# ---------------------------------------------------------------------------
# /jailbreak (Monopoly-style dice rolls)
# ---------------------------------------------------------------------------
@_locked_by(0)
async def set_jailbreak_used(user_id: int) -> None:
"""Mark that the user has consumed their dice attempt for this jail sentence."""
user = await get_user(user_id)
user["jailbreak_used"] = True
await _commit(user_id, user)
@_locked_by(0)
async def do_jail_free(user_id: int) -> dict:
"""Remove jail status after rolling doubles."""
user = await get_user(user_id)
user["jailed_until"] = None
user["jailbreak_used"] = False
await _commit(user_id, user)
_txn("JAIL_FREE", user=user_id, method="doubles")
return {"ok": True, "balance": user["balance"]}
MIN_BAIL = 350
@_locked_by(0)
async def do_bail(user_id: int) -> dict:
"""Charge bail fine after exhausting jailbreak rolls and free the user.
Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed."""
user = await get_user(user_id)
# Idempotency guard: only an actively-jailed user can be charged bail. The
# first successful call clears jailed_until, so a rapid second click or a
# stale BailView from a re-run /jailbreak becomes a no-op instead of a
# second fine (bail is a pure sink - a double charge destroys coins).
if not _is_jailed(user):
return {"ok": False, "reason": "not_jailed", "balance": user["balance"]}
if user["balance"] < MIN_BAIL:
return {"ok": False, "reason": "broke", "balance": user["balance"]}
pct = random.uniform(0.20, 0.30)
fine = max(MIN_BAIL, int(user["balance"] * pct))
user["balance"] = max(0, user["balance"] - fine)
user["jailed_until"] = None
user["jailbreak_used"] = False
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
user["total_bail_paid"] = user.get("total_bail_paid", 0) + fine
await _commit(user_id, user)
_txn("BAIL_PAID", user=user_id, fine=f"-{fine}", pct=f"{pct:.0%}", bal=user["balance"])
return {"ok": True, "fine": fine, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /jailed
# ---------------------------------------------------------------------------
async def do_get_jailed() -> list[tuple[int, timedelta]]:
"""Return [(user_id, remaining)] for every user currently in jail."""
all_users = await get_all_users_raw()
result: list[tuple[int, timedelta]] = []
for uid_str, user in all_users.items():
if rem := _is_jailed(user):
result.append((int(uid_str), rem))
result.sort(key=lambda x: x[1])
return result