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:
Rene Arumetsa
2026-08-10 18:31:46 +03:00
parent 8d16da268d
commit 968356f925
6 changed files with 220 additions and 63 deletions

View File

@@ -792,6 +792,11 @@ def register_economy_games_commands(
self._doubled_hands: set[int] = set()
self._split_aces: bool = False
self.message: discord.Message | None = None
# _busy: a callback is mid-flight (prevents overlapping button tasks,
# since discord.py dispatches each click as its own task).
# _resolved: the game has paid out (makes settlement idempotent).
self._busy = False
self._resolved = False
self._refresh_buttons()
@property
@@ -864,6 +869,9 @@ def register_economy_games_commands(
return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2)
async def _resolve_all(self, interaction: discord.Interaction) -> None:
if self._resolved:
return
self._resolved = True
active_games.discard(self.user_id)
self.clear_items()
self.stop()
@@ -949,80 +957,111 @@ def register_economy_games_commands(
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
await interaction.response.defer()
self._cur_hand.append(self.deck.pop())
val = _bj_value(self._cur_hand)
if val > 21:
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
if self._busy or self.is_finished():
await interaction.response.defer()
return
self._busy = True
try:
await interaction.response.defer()
self._cur_hand.append(self.deck.pop())
val = _bj_value(self._cur_hand)
if val > 21:
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
else:
await self._resolve_all(interaction)
elif val == 21:
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY * 0.5)
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
else:
await self._do_dealer_reveal(interaction)
else:
await self._resolve_all(interaction)
elif val == 21:
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY * 0.5)
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
else:
await self._do_dealer_reveal(interaction)
else:
self._refresh_buttons()
await self.message.edit(embed=self._cur_embed(), view=self)
self._refresh_buttons()
await self.message.edit(embed=self._cur_embed(), view=self)
finally:
self._busy = False
async def _stand(self, interaction: discord.Interaction) -> None:
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
await interaction.response.defer()
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
else:
await self._do_dealer_reveal(interaction)
if self._busy or self.is_finished():
await interaction.response.defer()
return
self._busy = True
try:
await interaction.response.defer()
if len(self.hands) > 1:
await self._advance_or_finish(interaction)
else:
await self._do_dealer_reveal(interaction)
finally:
self._busy = False
async def _double(self, interaction: discord.Interaction) -> None:
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
res = await economy.do_blackjack_bet(self.user_id, self.bet)
if not res["ok"]:
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
)
if self._busy or self.is_finished():
await interaction.response.defer()
return
await interaction.response.defer()
self._doubled_hands.add(0)
self.bets[0] *= 2
self._cur_hand.append(self.deck.pop())
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
await self._do_dealer_reveal(interaction)
self._busy = True
try:
res = await economy.do_blackjack_bet(self.user_id, self.bet)
if not res["ok"]:
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
)
return
await interaction.response.defer()
self._doubled_hands.add(0)
self.bets[0] *= 2
self._cur_hand.append(self.deck.pop())
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
await self._do_dealer_reveal(interaction)
finally:
self._busy = False
async def _split_hand(self, interaction: discord.Interaction) -> None:
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
res = await economy.do_blackjack_bet(self.user_id, self.bet)
if not res["ok"]:
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
)
if self._busy or self.is_finished():
await interaction.response.defer()
return
await interaction.response.defer()
card1, card2 = self._cur_hand[0], self._cur_hand[1]
self._split_aces = card1[0] == "A"
self.hands = [[card1, self.deck.pop()], [card2, self.deck.pop()]]
self.bets = [self.bet, self.bet]
self.hand_idx = 0
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
if self._split_aces:
await self._do_dealer_reveal(interaction)
else:
self._refresh_buttons()
await self.message.edit(embed=self._cur_embed(), view=self)
self._busy = True
try:
res = await economy.do_blackjack_bet(self.user_id, self.bet)
if not res["ok"]:
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(res.get("balance", 0))), ephemeral=True
)
return
await interaction.response.defer()
card1, card2 = self._cur_hand[0], self._cur_hand[1]
self._split_aces = card1[0] == "A"
self.hands = [[card1, self.deck.pop()], [card2, self.deck.pop()]]
self.bets = [self.bet, self.bet]
self.hand_idx = 0
await self.message.edit(embed=self._cur_embed(), view=None)
await asyncio.sleep(_BJ_DEAL_DELAY)
if self._split_aces:
await self._do_dealer_reveal(interaction)
else:
self._refresh_buttons()
await self.message.edit(embed=self._cur_embed(), view=self)
finally:
self._busy = False
async def on_timeout(self) -> None:
if self._resolved:
return
self._resolved = True
active_games.discard(self.user_id)
try:
await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets))