forked from sass/tipibot
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:
@@ -396,12 +396,17 @@ def register_economy_extra_commands(
|
||||
def __init__(self, user_id: int):
|
||||
super().__init__(timeout=60)
|
||||
self.user_id = user_id
|
||||
self._paying = False
|
||||
|
||||
@discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger)
|
||||
async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button):
|
||||
if interaction.user.id != self.user_id:
|
||||
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||||
return
|
||||
if self._paying or self.is_finished():
|
||||
await interaction.response.defer()
|
||||
return
|
||||
self._paying = True
|
||||
res = await economy.do_bail(self.user_id)
|
||||
self.clear_items()
|
||||
self.stop()
|
||||
@@ -414,6 +419,15 @@ def register_economy_extra_commands(
|
||||
),
|
||||
color=0xED4245,
|
||||
)
|
||||
elif not res["ok"]:
|
||||
# Already released (stale view or double-click) - no charge applied.
|
||||
embed = discord.Embed(
|
||||
title=S.TITLE["jailbreak_bail"],
|
||||
description=S.JAILBREAK_UI["bail_already_free"].format(
|
||||
balance=coin(res["balance"]),
|
||||
),
|
||||
color=0x57F287,
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
title=S.TITLE["jailbreak_bail"],
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user