1
0
forked from sass/tipibot
Files
tipibot/economy_games_commands.py
2026-04-20 12:09:39 +03:00

1133 lines
49 KiB
Python

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 = "<a:TipiSLOTS:1483444233863037101>"
_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,
)