from __future__ import annotations import asyncio import datetime import random from collections.abc import Awaitable, Callable, MutableSet import discord from discord import app_commands import economy import strings as S def register_economy_games_commands( tree: app_commands.CommandTree, coin: Callable[[int], str], cd_ts: Callable[[datetime.timedelta], str], parse_amount: Callable[[str, int], tuple[int | None, str | None]], gamble_cd: Callable[[int, bool], datetime.timedelta | None], award_exp: Callable[[discord.Interaction, int], Awaitable[None]], active_games: MutableSet[int], ) -> None: # ----------------------------------------------------------------------- # /roulette animation # ----------------------------------------------------------------------- _ROULETTE_R = "\U0001f534" # 🔴 _ROULETTE_B = "\u26ab" # ⚫ _ROULETTE_G = "\U0001f7e2" # 🟢 # delays between frames, fast → slow (12 transitions = 13 total viewport positions) _ROULETTE_WHEEL_DELAYS = [0.15, 0.15, 0.18, 0.20, 0.22, 0.25, 0.28, 0.35, 0.45, 0.60, 0.80, 1.00] def _build_roulette_strip(result_emoji: str) -> list[str]: """Build a 17-symbol wheel strip obeying strict transition rules: R -> B or G (R can go to either) B -> R (B must go to R) G -> B (G must go to B) Result is at strip[14] = center of the final viewport. Prefix (0-13) is generated backward from the result; suffix (15-16) is generated forward from the result. Greens appear randomly in the prefix as near-miss elements (up to 2). """ r, b, g = _ROULETTE_R, _ROULETTE_B, _ROULETTE_G strip: list[str] = [None] * 17 # type: ignore[list-item] # Suffix: positions 15-16 (deterministic, no greens after result) strip[14] = result_emoji strip[15] = r if result_emoji == b else b # B->R, R->B, G->B strip[16] = b if strip[15] == r else r # R->B, B->R # Prefix: positions 0-13 built backward from result # Inverse transitions: pred(R)=B, pred(B)=R or G, pred(G)=R # First pass: collect positions where a green is valid (cur == B, with room for pred). # Green is only relevant when result is not green itself. green_pos: int | None = None if result_emoji != g: candidates: list[int] = [] cur = result_emoji for i in range(13, -1, -1): if cur == b and 2 <= i <= 11: candidates.append(i) cur = b if cur == r else (r if cur == g else r) if candidates: green_pos = random.choice(candidates) # Second pass: generate strip, inserting green at the chosen position. cur = result_emoji for i in range(13, -1, -1): if cur == r: strip[i] = b elif cur == g: strip[i] = r else: # cur == B strip[i] = g if i == green_pos else r cur = strip[i] return strip def _roulette_frame_embed(symbols: list[str], stopped: bool = False) -> discord.Embed: title = S.ROULETTE["spin_stop"] if stopped else S.ROULETTE["spin_title"] desc = S.ROULETTE["spin_strip"].format( s0=symbols[0], s1=symbols[1], s2=symbols[2], s3=symbols[3], s4=symbols[4] ) return discord.Embed(title=title, description=desc, color=0x99AAB5) @tree.command(name="roulette", description=S.CMD["roulette"]) @app_commands.describe(panus=S.OPT["roulette_panus"], värv=S.OPT["roulette_värv"]) @app_commands.choices( värv=[ app_commands.Choice(name="🔴 Punane", value="punane"), app_commands.Choice(name="⚫ Must", value="must"), app_commands.Choice(name="🟢 Roheline", value="roheline"), ] ) async def cmd_roulette(interaction: discord.Interaction, panus: str, värv: app_commands.Choice[str]): data = await economy.get_user(interaction.user.id) panus_int, err = parse_amount(panus, data["balance"]) if err: await interaction.response.send_message(err, ephemeral=True) return if panus_int is None or panus_int <= 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return has_360 = "monitor_360" in data.get("items", []) if rem := gamble_cd(interaction.user.id, has_360): await interaction.response.send_message( S.ERR["gamble_cooldown"].format(ts=cd_ts(rem)), ephemeral=True ) return if interaction.user.id in active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return active_games.add(interaction.user.id) res = await economy.do_roulette(interaction.user.id, panus_int, värv.value) 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": await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), ephemeral=True ) else: await interaction.response.send_message( S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True ) return result_emoji = S.ROULETTE["emoji"][res["result"]] strip = _build_roulette_strip(result_emoji) try: await interaction.response.send_message(embed=_roulette_frame_embed(strip[0:5])) spin_msg = await interaction.original_response() for i, delay in enumerate(_ROULETTE_WHEEL_DELAYS, 1): await asyncio.sleep(delay) stopped = i == len(_ROULETTE_WHEEL_DELAYS) await spin_msg.edit(embed=_roulette_frame_embed(strip[i : i + 5], stopped=stopped)) await asyncio.sleep(0.55) emoji = S.ROULETTE["emoji"].get(res["result"], "🎰") genitive = S.ROULETTE["genitive"].get(res["result"], res["result"]) if res["won"]: mult_str = f" · **{res['mult']}x**" if res["mult"] > 1 else "" embed = discord.Embed( title=S.ROULETTE["win_title"].format(emoji=emoji), description=S.ROULETTE["win_desc"].format( genitive=genitive, mult=mult_str, change=coin(res["change"]), balance=coin(res["balance"]), ), color=0x57F287, ) asyncio.create_task(award_exp(interaction, economy.gamble_exp(panus_int))) else: embed = discord.Embed( title=S.ROULETTE["lose_title"].format(emoji=emoji), description=S.ROULETTE["lose_desc"].format( genitive=genitive, change=coin(abs(res["change"])), balance=coin(res["balance"]), ), color=0xED4245, ) await spin_msg.edit(embed=embed) finally: active_games.discard(interaction.user.id) # ----------------------------------------------------------------------- # Rock Paper Scissors (vs Bot OR PvP) # ----------------------------------------------------------------------- _RPS_CHOICES = S.RPS_CHOICES _RPS_BEATS = {"🪨": "✂️", "📄": "🪨", "✂️": "📄"} class RPSView(discord.ui.View): def __init__(self, challenger: discord.User, bet: int = 0): super().__init__(timeout=60) self.challenger = challenger self.bet = bet async def _resolve(self, interaction: discord.Interaction, player_pick: str): if interaction.user.id != self.challenger.id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return self.stop() active_games.discard(self.challenger.id) bot_pick = random.choice(list(_RPS_CHOICES)) p_name = _RPS_CHOICES[player_pick] b_name = _RPS_CHOICES[bot_pick] if player_pick == bot_pick: outcome, result, color = "tie", S.RPS_UI["result_tie"], 0x99AAB5 elif _RPS_BEATS[player_pick] == bot_pick: outcome, result, color = "win", S.RPS_UI["result_win"], 0x57F287 else: outcome, result, color = "lose", S.RPS_UI["result_lose"], 0xED4245 bet_line = "" if self.bet > 0: res = await economy.do_game_bet(interaction.user.id, self.bet, outcome) if outcome == "win": bet_line = S.RPS_UI["bet_win"].format(amount=coin(self.bet), balance=coin(res["balance"])) elif outcome == "lose": bet_line = S.RPS_UI["bet_lose"].format(amount=coin(self.bet), balance=coin(res["balance"])) else: bet_line = S.RPS_UI["bet_tie"].format(balance=coin(res["balance"])) embed = discord.Embed( title=S.TITLE["rps"], description=S.RPS_UI["result_desc"].format( player_pick=player_pick, player_name=p_name, bot_pick=bot_pick, bot_name=b_name, result=result, bet_line=bet_line, ), color=color, ) await interaction.response.edit_message(embed=embed, view=None) @discord.ui.button(label=S.RPS_UI["btn_rock"], style=discord.ButtonStyle.secondary) async def pick_rock(self, interaction: discord.Interaction, _: discord.ui.Button): await self._resolve(interaction, "🪨") @discord.ui.button(label=S.RPS_UI["btn_paper"], style=discord.ButtonStyle.secondary) async def pick_paper(self, interaction: discord.Interaction, _: discord.ui.Button): await self._resolve(interaction, "📄") @discord.ui.button(label=S.RPS_UI["btn_scissors"], style=discord.ButtonStyle.secondary) async def pick_scissors(self, interaction: discord.Interaction, _: discord.ui.Button): await self._resolve(interaction, "✂️") async def on_timeout(self): active_games.discard(self.challenger.id) for item in self.children: item.disabled = True class RpsGame: """Shared mutable state for a PvP RPS match.""" def __init__(self, player_a: discord.Member, player_b: discord.Member, bet: int): self.player_a = player_a self.player_b = player_b self.bet = bet self.choice_a: str | None = None self.choice_b: str | None = None self.dm_msg_a: discord.Message | None = None self.dm_msg_b: discord.Message | None = None self.server_message: discord.Message | None = None self._resolved = False self._lock = asyncio.Lock() async def maybe_resolve(self) -> None: async with self._lock: if self._resolved or self.choice_a is None or self.choice_b is None: return self._resolved = True a, b = self.choice_a, self.choice_b if a == b: winner, color = None, 0x99AAB5 result_a = result_b = S.RPS_UI["result_tie"] elif _RPS_BEATS[a] == b: winner, color = "a", 0x57F287 result_a = S.RPS_UI["result_win"] result_b = S.RPS_UI["duel_verdict_win"].format(name=self.player_a.display_name) else: winner, color = "b", 0xED4245 result_a = S.RPS_UI["duel_verdict_win"].format(name=self.player_b.display_name) result_b = S.RPS_UI["result_win"] bet_line_a = bet_line_b = "" if self.bet > 0: if winner == "a": res = await economy.do_give(self.player_b.id, self.player_a.id, self.bet) elif winner == "b": res = await economy.do_give(self.player_a.id, self.player_b.id, self.bet) else: res = {"ok": True} if self.bet > 0 and winner is not None: if res.get("ok"): bet_line_a = f"\n{'+' if winner == 'a' else '-'}{coin(self.bet)}" bet_line_b = f"\n{'+' if winner == 'b' else '-'}{coin(self.bet)}" else: bet_line_a = bet_line_b = S.RPS_UI["duel_broke"] data_a = await economy.get_user(self.player_a.id) data_b = await economy.get_user(self.player_b.id) bal_a, bal_b = data_a["balance"], data_b["balance"] if self.dm_msg_a: await self.dm_msg_a.edit( content=S.RPS_UI["duel_result_a"].format( opponent=self.player_b.display_name, pick_a=a, name_a=_RPS_CHOICES[a], pick_b=b, name_b=_RPS_CHOICES[b], result=result_a, bet_line=bet_line_a, balance=coin(bal_a), ), view=None, ) if self.dm_msg_b: await self.dm_msg_b.edit( content=S.RPS_UI["duel_result_a"].format( opponent=self.player_a.display_name, pick_a=b, name_a=_RPS_CHOICES[b], pick_b=a, name_b=_RPS_CHOICES[a], result=result_b, bet_line=bet_line_b, balance=coin(bal_b), ), view=None, ) if self.server_message: if winner == "a": verdict = S.RPS_UI["duel_verdict_win"].format(name=self.player_a.display_name) elif winner == "b": verdict = S.RPS_UI["duel_verdict_win"].format(name=self.player_b.display_name) else: verdict = S.RPS_UI["duel_verdict_tie"] embed = discord.Embed( title=S.TITLE["rps_duel_done"], description=S.RPS_UI["duel_done_desc"].format( a=self.player_a.mention, pick_a=a, pick_b=b, b=self.player_b.mention, verdict=verdict, name_a=self.player_a.display_name, bal_a=coin(bal_a), name_b=self.player_b.display_name, bal_b=coin(bal_b), ), color=color, ) await self.server_message.edit(embed=embed, view=None) active_games.discard(self.player_a.id) active_games.discard(self.player_b.id) class RpsDmView(discord.ui.View): """DM view for each player to make their pick in a PvP match.""" def __init__(self, game: RpsGame, side: str): super().__init__(timeout=120) self.game = game self.side = side async def _pick(self, interaction: discord.Interaction, choice: str) -> None: if self.side == "a": self.game.choice_a = choice else: self.game.choice_b = choice for item in self.children: item.disabled = True self.stop() await interaction.response.edit_message( content=S.RPS_UI["duel_waiting"].format(choice=choice, name=_RPS_CHOICES[choice]), view=self, ) await self.game.maybe_resolve() async def on_timeout(self) -> None: async with self.game._lock: if self.game._resolved: return self.game._resolved = True active_games.discard(self.game.player_a.id) active_games.discard(self.game.player_b.id) for item in self.children: item.disabled = True for player in (self.game.player_a, self.game.player_b): try: await player.send(S.RPS_UI["duel_expire_dm"]) except discord.Forbidden: pass if self.game.server_message: embed = discord.Embed( title=S.TITLE["rps_duel_expire"], description=S.RPS_UI["duel_expire_desc"].format( a=self.game.player_a.mention, b=self.game.player_b.mention, ), color=0x99AAB5, ) await self.game.server_message.edit(embed=embed, view=None) @discord.ui.button(label=S.RPS_UI["btn_rock"], style=discord.ButtonStyle.secondary) async def pick_rock(self, interaction: discord.Interaction, _: discord.ui.Button): await self._pick(interaction, "🪨") @discord.ui.button(label=S.RPS_UI["btn_paper"], style=discord.ButtonStyle.secondary) async def pick_paper(self, interaction: discord.Interaction, _: discord.ui.Button): await self._pick(interaction, "📄") @discord.ui.button(label=S.RPS_UI["btn_scissors"], style=discord.ButtonStyle.secondary) async def pick_scissors(self, interaction: discord.Interaction, _: discord.ui.Button): await self._pick(interaction, "✂️") class RpsChallengeView(discord.ui.View): """Server-side accept/decline view for PvP RPS challenge.""" def __init__(self, game: RpsGame): super().__init__(timeout=60) self.game = game def _disable_all(self) -> None: for item in self.children: item.disabled = True @discord.ui.button(label=S.RPS_UI["btn_accept"], style=discord.ButtonStyle.success) async def accept(self, interaction: discord.Interaction, _: discord.ui.Button): if interaction.user.id != self.game.player_b.id: await interaction.response.send_message(S.ERR["not_your_challenge"], ephemeral=True) return if self.game.player_b.id in active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return self.stop() self._disable_all() active_games.add(self.game.player_b.id) if self.game.bet > 0: data_a = await economy.get_user(self.game.player_a.id) data_b = await economy.get_user(self.game.player_b.id) for player, data in ((self.game.player_a, data_a), (self.game.player_b, data_b)): if data["balance"] < self.game.bet: embed = discord.Embed( title=S.TITLE["rps_duel_cancel"], description=S.RPS_UI["duel_insufficient"].format(mention=player.mention), color=0xED4245, ) await interaction.response.edit_message(embed=embed, view=None) async with self.game._lock: self.game._resolved = True active_games.discard(self.game.player_a.id) active_games.discard(self.game.player_b.id) return bet_str = S.RPS_UI["duel_active_bet"].format(bet=coin(self.game.bet)) if self.game.bet > 0 else "" embed = discord.Embed( title=S.TITLE["rps_duel_active"], description=S.RPS_UI["duel_active_desc"].format( a=self.game.player_a.mention, b=self.game.player_b.mention, bet=bet_str, ), color=0x5865F2, ) await interaction.response.edit_message(embed=embed, view=self) bet_dm = S.RPS_UI["duel_dm_bet"].format(bet=coin(self.game.bet)) if self.game.bet > 0 else "" dm_failed: list[str] = [] for player, side in ((self.game.player_a, "a"), (self.game.player_b, "b")): view = RpsDmView(self.game, side) opponent = self.game.player_b if side == "a" else self.game.player_a try: msg = await player.send( S.RPS_UI["duel_dm"].format(opponent=opponent.display_name, bet=bet_dm), view=view, ) if side == "a": self.game.dm_msg_a = msg else: self.game.dm_msg_b = msg except discord.Forbidden: dm_failed.append(player.display_name) if dm_failed: async with self.game._lock: self.game._resolved = True active_games.discard(self.game.player_a.id) active_games.discard(self.game.player_b.id) embed = discord.Embed( title=S.TITLE["rps_duel_cancel"], description=S.RPS_UI["duel_dm_fail"].format(names=", ".join(dm_failed)), color=0xED4245, ) await self.game.server_message.edit(embed=embed, view=None) @discord.ui.button(label=S.RPS_UI["btn_decline"], style=discord.ButtonStyle.danger) async def decline(self, interaction: discord.Interaction, _: discord.ui.Button): if interaction.user.id != self.game.player_b.id: await interaction.response.send_message(S.ERR["not_your_challenge"], ephemeral=True) return self.stop() self._disable_all() active_games.discard(self.game.player_a.id) embed = discord.Embed( title=S.TITLE["rps_duel_decline"], description=S.RPS_UI["duel_decline"].format(name=self.game.player_b.display_name), color=0xED4245, ) await interaction.response.edit_message(embed=embed, view=self) async def on_timeout(self) -> None: active_games.discard(self.game.player_a.id) self._disable_all() if self.game.server_message: embed = discord.Embed( title=S.TITLE["rps_duel_expire"], description=S.RPS_UI["duel_no_answer"].format(name=self.game.player_b.display_name), color=0x99AAB5, ) await self.game.server_message.edit(embed=embed, view=self) @tree.command(name="rps", description=S.CMD["rps"]) @app_commands.describe(panus=S.OPT["rps_panus"], vastane=S.OPT["rps_vastane"]) async def cmd_rps(interaction: discord.Interaction, panus: str = "0", vastane: discord.Member | None = None): data = await economy.get_user(interaction.user.id) panus_int, err = parse_amount(panus, data["balance"]) if err: await interaction.response.send_message(err, ephemeral=True) return if panus_int is None or panus_int < 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return if panus_int > 0: if rem := economy.jailed_remaining(data): await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=cd_ts(rem)), ephemeral=True ) return # PvP mode if vastane is not None: if interaction.user.id in active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return if vastane.id == interaction.user.id: await interaction.response.send_message(S.ERR["rps_self"], ephemeral=True) return if vastane.bot: await interaction.response.send_message(S.ERR["rps_bot"], ephemeral=True) return if panus_int > 0 and data["balance"] < panus_int: await interaction.response.send_message( S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True ) return game = RpsGame(interaction.user, vastane, panus_int) bet_challenge = S.RPS_UI["challenge_bet"].format(bet=coin(panus_int)) if panus_int > 0 else "" embed = discord.Embed( title=S.TITLE["rps_duel"], description=S.RPS_UI["challenge_desc"].format( challenger=interaction.user.mention, opponent=vastane.mention, bet=bet_challenge, ), color=0x5865F2, ) 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() return # vs Bot mode if panus_int > 0 and data["balance"] < panus_int: await interaction.response.send_message( S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True ) return if interaction.user.id in active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return if panus_int > 0: has_360 = "monitor_360" in data.get("items", []) if rem := gamble_cd(interaction.user.id, has_360): await interaction.response.send_message( S.ERR["gamble_cooldown"].format(ts=cd_ts(rem)), ephemeral=True ) return active_games.add(interaction.user.id) bet_str = S.RPS_UI["vs_bot_bet"].format(bet=coin(panus_int)) if panus_int > 0 else "" embed = discord.Embed( title=S.TITLE["rps"], description=S.RPS_UI["vs_bot_desc"] + bet_str, color=0x5865F2, ) await interaction.response.send_message(embed=embed, view=RPSView(interaction.user, panus_int)) # ----------------------------------------------------------------------- # /slots # ----------------------------------------------------------------------- _SLOTS_SPIN = "" _SLOTS_DELAY = 0.7 def _slots_embed( r1: str, r2: str, r3: str, title: str = "", # set dynamically color: int = 0x5865F2, footer: str = "", ) -> discord.Embed: desc = f"{r1} | {r2} | {r3}" if footer: desc += f"\n\n{footer}" return discord.Embed(title=title, description=desc, color=color) @tree.command(name="slots", description=S.CMD["slots"]) @app_commands.describe(panus=S.OPT["slots_panus"]) async def cmd_slots(interaction: discord.Interaction, panus: str): data = await economy.get_user(interaction.user.id) panus_int, err = parse_amount(panus, data["balance"]) if err: await interaction.response.send_message(err, ephemeral=True) return if panus_int is None or panus_int <= 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return has_360 = "monitor_360" in data.get("items", []) if rem := gamble_cd(interaction.user.id, has_360): await interaction.response.send_message( S.ERR["gamble_cooldown"].format(ts=cd_ts(rem)), ephemeral=True ) return if interaction.user.id in active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return active_games.add(interaction.user.id) res = await economy.do_slots(interaction.user.id, panus_int) if not res["ok"]: active_games.discard(interaction.user.id) if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) return if 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( S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True ) return reels = res["reels"] tier = res["tier"] change = res["change"] sp = _SLOTS_SPIN try: await interaction.response.send_message(embed=_slots_embed(sp, sp, sp, title=S.SLOTS_UI["playing"])) msg = await interaction.original_response() await asyncio.sleep(_SLOTS_DELAY) await msg.edit(embed=_slots_embed(reels[0], sp, sp, title=S.SLOTS_UI["playing"])) await asyncio.sleep(_SLOTS_DELAY) await msg.edit(embed=_slots_embed(reels[0], reels[1], sp, title=S.SLOTS_UI["playing"])) await asyncio.sleep(_SLOTS_DELAY) await msg.edit(embed=_slots_embed(reels[0], reels[1], reels[2], title=S.SLOTS_UI["playing"])) await asyncio.sleep(_SLOTS_DELAY * 0.6) tier_key = tier if tier in S.SLOTS_TIERS else "miss" title, color = S.SLOTS_TIERS[tier_key] if tier == "jackpot": footer = S.SLOTS_UI["jackpot_footer"].format(change=coin(change)) elif tier == "triple": footer = S.SLOTS_UI["triple_footer"].format(change=coin(change)) elif tier == "pair": footer = S.SLOTS_UI["pair_footer"].format(change=coin(change)) else: footer = S.SLOTS_UI["miss_footer"].format(amount=coin(panus_int)) footer += S.SLOTS_UI["balance_line"].format(balance=coin(res["balance"])) await msg.edit( embed=_slots_embed( reels[0], reels[1], reels[2], title=title, color=color, footer=footer, ) ) if tier in ("jackpot", "triple", "pair"): asyncio.create_task(award_exp(interaction, economy.gamble_exp(panus_int))) finally: active_games.discard(interaction.user.id) # ----------------------------------------------------------------------- # /blackjack # ----------------------------------------------------------------------- _BJ_RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] _BJ_SUITS = ["♠", "♥", "♦", "♣"] _BJ_DEAL_DELAY = 0.65 def _bj_deck() -> list[tuple[str, str]]: deck = [(r, s) for r in _BJ_RANKS for s in _BJ_SUITS] random.shuffle(deck) return deck def _bj_value(hand: list[tuple[str, str]]) -> int: total, aces = 0, 0 for rank, _ in hand: if rank == "A": total += 11 aces += 1 elif rank in ("J", "Q", "K", "10"): total += 10 else: total += int(rank) while total > 21 and aces: total -= 10 aces -= 1 return total def _bj_hand_str(hand: list[tuple[str, str]], hide_second: bool = False) -> str: if hide_second and len(hand) >= 2: return f"`{hand[0][0]}{hand[0][1]}` `🂠`" return " ".join(f"`{r}{s}`" for r, s in hand) def _bj_is_blackjack(hand: list[tuple[str, str]]) -> bool: return len(hand) == 2 and _bj_value(hand) == 21 def _bj_embed( player_hand: list, dealer_hand: list, title: str, color: int, *, hide_dealer: bool = True, doubled_total: int = 0, result_field: tuple | None = None, ) -> discord.Embed: p_str = _bj_hand_str(player_hand) if player_hand else "-" p_val = f" `{_bj_value(player_hand)}`" if player_hand else "" if not dealer_hand: d_str, d_val = "-", "" elif hide_dealer: d_str = _bj_hand_str(dealer_hand, hide_second=True) d_val = f" `{_bj_value([dealer_hand[0]])}`" else: d_str = _bj_hand_str(dealer_hand) d_val = f" `{_bj_value(dealer_hand)}`" desc = f"**{S.BJ_UI['dealer']}:** {d_str}{d_val}\n**{S.BJ_UI['player']}:** {p_str}{p_val}" if doubled_total: desc += "\n" + S.BJ["doubled_label"].format(total=coin(doubled_total)) embed = discord.Embed(title=title, description=desc, color=color) if result_field: embed.add_field(name=result_field[0], value=result_field[1], inline=False) return embed class BlackjackView(discord.ui.View): def __init__( self, user_id: int, bet: int, player_hand: list, dealer_hand: list, deck: list, ): super().__init__(timeout=120) self.user_id = user_id self.bet = bet self.hands: list[list] = [player_hand] self.bets: list[int] = [bet] self.hand_idx: int = 0 self.dealer_hand = dealer_hand self.deck = deck self._doubled_hands: set[int] = set() self._split_aces: bool = False self.message: discord.Message | None = None self._refresh_buttons() @property def _cur_hand(self) -> list: return self.hands[self.hand_idx] def _can_split(self) -> bool: return ( len(self.hands) == 1 and len(self._cur_hand) == 2 and self._cur_hand[0][0] == self._cur_hand[1][0] ) def _refresh_buttons(self) -> None: self.clear_items() is_split = len(self.hands) > 1 can_double = not is_split and 0 not in self._doubled_hands and len(self._cur_hand) == 2 hit_btn = discord.ui.Button(label=S.BJ["btn_hit"], style=discord.ButtonStyle.primary) hit_btn.callback = self._hit stand_btn = discord.ui.Button(label=S.BJ["btn_stand"], style=discord.ButtonStyle.secondary) stand_btn.callback = self._stand double_btn = discord.ui.Button( label=S.BJ["btn_double"].format(bet=self.bet), style=discord.ButtonStyle.success, disabled=not can_double, ) double_btn.callback = self._double self.add_item(hit_btn) self.add_item(stand_btn) self.add_item(double_btn) if self._can_split(): split_btn = discord.ui.Button( label=S.BJ["btn_split"].format(bet=self.bet), style=discord.ButtonStyle.danger, ) split_btn.callback = self._split_hand self.add_item(split_btn) def _cur_embed(self, game_over: bool = False, hand_results: list | None = None) -> discord.Embed: if not self.dealer_hand: d_str, d_val = "-", "" elif not game_over: d_str = _bj_hand_str(self.dealer_hand, hide_second=True) d_val = f" `{_bj_value([self.dealer_hand[0]])}`" else: d_str = _bj_hand_str(self.dealer_hand) d_val = f" `{_bj_value(self.dealer_hand)}`" desc = f"**{S.BJ_UI['dealer']}:** {d_str}{d_val}" if len(self.hands) == 1: hand = self.hands[0] pv = _bj_value(hand) doubled_str = f" 💰 *{coin(self.bets[0])}*" if 0 in self._doubled_hands else "" desc += f"\n**{S.BJ_UI['player']}:** {_bj_hand_str(hand)} `{pv}`{doubled_str}" else: for i, hand in enumerate(self.hands): pv = _bj_value(hand) if hand_results and i < len(hand_results): icon = {"win": "✅", "push": "🤝", "lose": "❌"}[hand_results[i]] label = f"{icon} " + S.BJ_UI["hand_n"].format(n=i + 1) elif game_over or i < self.hand_idx: label = S.BJ_UI["hand_n"].format(n=i + 1) elif i == self.hand_idx: label = S.BJ_UI["hand_active"].format(n=i + 1) else: label = S.BJ_UI["hand_pending"].format(n=i + 1) bust_str = S.BJ_UI["bust"] if pv > 21 else "" desc += f"\n**{label}:** {_bj_hand_str(hand)} `{pv}`{bust_str}" return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2) async def _resolve_all(self, interaction: discord.Interaction) -> None: active_games.discard(self.user_id) self.clear_items() self.stop() dv = _bj_value(self.dealer_hand) total_payout = 0 hand_results: list[str] = [] for hand, bet in zip(self.hands, self.bets): pv = _bj_value(hand) if pv > 21: hand_results.append("lose") elif dv > 21 or pv > dv: hand_results.append("win") total_payout += bet * 2 elif pv == dv: hand_results.append("push") total_payout += bet else: hand_results.append("lose") total_invested = sum(self.bets) res = await economy.do_blackjack_payout(self.user_id, total_payout, total_invested) net = total_payout - total_invested result_str = ( f"+{coin(total_payout)}" if net > 0 else (S.BJ["push_result"] if net == 0 else f"-{coin(total_invested)}") ) if len(self.hands) == 1: r = hand_results[0] doubled = 0 in self._doubled_hands if r == "win": title_key, color = ("blackjack_dwin" if doubled else "blackjack_win"), 0x57F287 elif r == "push": title_key, color = "blackjack_push", 0x99AAB5 else: pv = _bj_value(self.hands[0]) if pv > 21: title_key = "blackjack_dbust" if doubled else "blackjack_bust" else: title_key = "blackjack_lose" color = 0xED4245 else: if net > 0: title_key, color = "blackjack_win", 0x57F287 elif net == 0: title_key, color = "blackjack_push", 0x99AAB5 else: title_key, color = "blackjack_lose", 0xED4245 embed = self._cur_embed(game_over=True, hand_results=hand_results) embed.title = S.TITLE[title_key] embed.color = color embed.add_field( name=S.BJ["result_field"], value=result_str + S.BJ_UI["balance_line"].format(balance=coin(res["balance"])), inline=False, ) await self.message.edit(embed=embed, view=self) if total_payout > total_invested: asyncio.create_task(award_exp(interaction, economy.gamble_exp(total_invested))) async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None: await self.message.edit(embed=self._cur_embed(game_over=True), view=None) await asyncio.sleep(_BJ_DEAL_DELAY) while _bj_value(self.dealer_hand) < 17: self.dealer_hand.append(self.deck.pop()) await self.message.edit(embed=self._cur_embed(game_over=True), view=None) await asyncio.sleep(_BJ_DEAL_DELAY) await self._resolve_all(interaction) async def _advance_or_finish(self, interaction: discord.Interaction) -> None: self.hand_idx += 1 if self.hand_idx < len(self.hands): self._refresh_buttons() await self.message.edit(embed=self._cur_embed(), view=self) else: self.hand_idx = len(self.hands) - 1 await self._do_dealer_reveal(interaction) async def _hit(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() 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: self._refresh_buttons() await self.message.edit(embed=self._cur_embed(), view=self) 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) 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 ) 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) 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 ) 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) async def on_timeout(self) -> None: active_games.discard(self.user_id) try: await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets)) except Exception: pass self.clear_items() if self.message: try: await self.message.edit(view=self) except discord.HTTPException: pass @tree.command(name="blackjack", description=S.CMD["blackjack"]) @app_commands.describe(panus=S.OPT["blackjack_panus"]) async def cmd_blackjack(interaction: discord.Interaction, panus: str): data = await economy.get_user(interaction.user.id) bet, err = parse_amount(panus, data["balance"]) if err: await interaction.response.send_message(err, ephemeral=True) return if bet is None or bet <= 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return has_360 = "monitor_360" in data.get("items", []) if rem := gamble_cd(interaction.user.id, has_360): await interaction.response.send_message( S.ERR["gamble_cooldown"].format(ts=cd_ts(rem)), 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_blackjack_bet(interaction.user.id, bet) 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 ) else: await interaction.response.send_message( S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True ) return active_games.add(interaction.user.id) deck = _bj_deck() player_hand: list = [] dealer_hand: list = [] await interaction.response.send_message( embed=discord.Embed(title=S.TITLE["blackjack"], description=S.BJ["dealing"], color=0x5865F2) ) msg = await interaction.original_response() for target in ["player", "dealer", "player", "dealer"]: if target == "player": player_hand.append(deck.pop()) else: dealer_hand.append(deck.pop()) await asyncio.sleep(_BJ_DEAL_DELAY) await msg.edit( embed=_bj_embed( player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=True, ) ) await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) if _bj_is_blackjack(player_hand): await msg.edit( embed=_bj_embed( player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=False, ) ) await asyncio.sleep(_BJ_DEAL_DELAY) if _bj_is_blackjack(dealer_hand): push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet) embed = _bj_embed( player_hand, dealer_hand, S.TITLE["blackjack_push"], 0x99AAB5, hide_dealer=False, result_field=( S.BJ["result_field"], S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=coin(push_res["balance"])), ), ) else: payout = bet + int(bet * 1.5) bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet) embed = _bj_embed( player_hand, dealer_hand, S.TITLE["blackjack_bj"], 0xF4C430, hide_dealer=False, result_field=( S.BJ["result_field"], f"+{coin(payout)}" + S.BJ_UI["balance_line"].format(balance=coin(bj_res["balance"])), ), ) asyncio.create_task(award_exp(interaction, economy.gamble_exp(bet))) active_games.discard(interaction.user.id) await msg.edit(embed=embed) return view = BlackjackView(interaction.user.id, bet, player_hand, dealer_hand, deck) view.message = msg await msg.edit( embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=True), view=view, )