add per-user locks, fix game conditons, startup config validation

This commit is contained in:
Rene Arumetsa
2026-07-26 20:03:56 +03:00
parent fdb0e5eb5b
commit fb3a91fc71
4 changed files with 218 additions and 34 deletions

83
bot.py
View File

@@ -1774,15 +1774,6 @@ class HeistLobbyView(discord.ui.View):
@discord.ui.button(label=S.HEIST_UI["btn_join"], style=discord.ButtonStyle.danger)
async def join(self, interaction: discord.Interaction, _: discord.ui.Button):
if any(p.id == interaction.user.id for p in self.participants):
await interaction.response.send_message(S.HEIST_UI["already_joined"], ephemeral=True)
return
if len(self.participants) >= _HEIST_MAX_PLAYERS:
await interaction.response.send_message(S.ERR["heist_full"], ephemeral=True)
return
if interaction.user.id in _active_games:
await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True)
return
res = await economy.do_heist_check(interaction.user.id)
if not res["ok"]:
if res["reason"] == "banned":
@@ -1796,6 +1787,17 @@ class HeistLobbyView(discord.ui.View):
S.CD_MSG["heist"].format(ts=_cd_ts(res["remaining"])), ephemeral=True
)
return
# These checks must come after the await above: a double-click could
# otherwise pass them twice and join the lobby twice.
if any(p.id == interaction.user.id for p in self.participants):
await interaction.response.send_message(S.HEIST_UI["already_joined"], ephemeral=True)
return
if len(self.participants) >= _HEIST_MAX_PLAYERS:
await interaction.response.send_message(S.ERR["heist_full"], ephemeral=True)
return
if interaction.user.id in _active_games:
await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True)
return
self.participants.append(interaction.user)
_active_games.add(interaction.user.id)
await interaction.response.edit_message(embed=self._lobby_embed())
@@ -1923,31 +1925,42 @@ async def cmd_heist(interaction: discord.Interaction):
if _active_heist is not None:
await interaction.response.send_message(S.ERR["heist_active"], ephemeral=True)
return
_heist_cd = await economy.get_heist_global_cd()
if time.time() < _heist_cd:
await interaction.response.send_message(
S.CD_MSG["heist_global"].format(ts=_cd_ts(datetime.timedelta(seconds=_heist_cd - time.time()))),
ephemeral=True,
)
return
if interaction.user.id in _active_games:
await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True)
return
res = await economy.do_heist_check(interaction.user.id)
if not res["ok"]:
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
await interaction.response.send_message(
S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True
)
return
# Claim the lobby singleton and the game slot before the awaits below -
# concurrent /heist invocations would otherwise all pass the checks above
# and the last lobby would silently overwrite the others.
view = HeistLobbyView(interaction.user)
_active_heist = view
_active_games.add(interaction.user.id)
await interaction.response.send_message(embed=view._lobby_embed(), view=view)
view.message = await interaction.original_response()
opened = False
try:
_heist_cd = await economy.get_heist_global_cd()
if time.time() < _heist_cd:
await interaction.response.send_message(
S.CD_MSG["heist_global"].format(ts=_cd_ts(datetime.timedelta(seconds=_heist_cd - time.time()))),
ephemeral=True,
)
return
res = await economy.do_heist_check(interaction.user.id)
if not res["ok"]:
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
await interaction.response.send_message(
S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True
)
return
await interaction.response.send_message(embed=view._lobby_embed(), view=view)
view.message = await interaction.original_response()
opened = True
finally:
if not opened:
if _active_heist is view:
_active_heist = None
_active_games.discard(interaction.user.id)
# ---------------------------------------------------------------------------
@@ -2898,9 +2911,13 @@ async def cmd_rps(interaction: discord.Interaction, panus: str = "0", vastane: d
)
embed.set_footer(text=S.RPS_UI["challenge_footer"])
challenge_view = RpsChallengeView(game)
await interaction.response.send_message(embed=embed, view=challenge_view)
_active_games.add(interaction.user.id)
game.server_message = await interaction.original_response()
try:
await interaction.response.send_message(embed=embed, view=challenge_view)
game.server_message = await interaction.original_response()
except Exception:
_active_games.discard(interaction.user.id)
raise
return
# ── vs Bot mode ──────────────────────────────────────────────────────
@@ -3360,9 +3377,13 @@ async def cmd_blackjack(interaction: discord.Interaction, panus: str):
if interaction.user.id in _active_games:
await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True)
return
# Claim the game slot before the bet await - a double-invoke in that window
# would otherwise pass the check twice and deduct two bets.
_active_games.add(interaction.user.id)
res = await economy.do_blackjack_bet(interaction.user.id, bet)
if not res["ok"]:
_active_games.discard(interaction.user.id)
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
@@ -3374,7 +3395,6 @@ async def cmd_blackjack(interaction: discord.Interaction, panus: str):
S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True
)
return
_active_games.add(interaction.user.id)
deck = _bj_deck()
player_hand: list = []
@@ -3790,6 +3810,7 @@ def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -
if __name__ == "__main__":
if not config.DISCORD_TOKEN:
raise SystemExit("DISCORD_TOKEN pole seadistatud. Kopeeri .env.example failiks .env ja täida see.")
economy.validate_config() # fail at boot on constants/strings drift, not at command time
async def _main() -> None:
loop = asyncio.get_event_loop()