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>
This commit is contained in:
@@ -7,7 +7,7 @@ import random
|
||||
from .. import pb_client
|
||||
from ..pb_client import DatabaseError
|
||||
from . import house
|
||||
from .store import HEIST_JAIL, _commit, _now, _txn, _user_lock, get_user
|
||||
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
|
||||
|
||||
|
||||
@@ -39,19 +39,25 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
||||
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 = await get_user(house.HOUSE_ID)
|
||||
house_rec = await get_user(house.HOUSE_ID)
|
||||
pct = random.uniform(0.20, 0.55)
|
||||
total = max(300, int(house["balance"] * pct))
|
||||
payout_each = total // len(user_ids)
|
||||
# Atomic decrement (capped at the balance we read) instead of a full
|
||||
# record commit, so concurrent _credit_house increments aren't lost.
|
||||
debit = min(total, house["balance"])
|
||||
# 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["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item]
|
||||
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["balance"] - debit)
|
||||
_txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house_rec["balance"] - debit)
|
||||
|
||||
for uid in user_ids:
|
||||
async with _user_lock(uid):
|
||||
|
||||
Reference in New Issue
Block a user