diff --git a/bot.py b/bot.py index 72f4047..f51fd6f 100644 --- a/bot.py +++ b/bot.py @@ -7,7 +7,6 @@ import json import logging import logging.handlers import math -import random import re import time from pathlib import Path @@ -28,7 +27,13 @@ import sheets from dev_member_commands import register_dev_member_commands from dev_member_runtime import handle_member_join, run_birthday_daily from economy_admin_commands import register_economy_admin_commands +from economy_extra_commands import register_economy_extra_commands +from economy_fish_commands import register_economy_fish_commands +from economy_games_commands import register_economy_games_commands +from economy_income_commands import register_economy_income_commands from economy_prestige_commands import register_prestige_commands +from economy_profile_commands import register_economy_profile_commands +from economy_support_commands import register_economy_support_commands from ops_channel_commands import register_ops_channel_commands from ops_admin_commands import register_ops_admin_commands from member_sync import SyncResult @@ -97,7 +102,6 @@ _process = psutil.Process() _DATA_DIR = Path("data") / config.BOT_PROFILE _DATA_DIR.mkdir(parents=True, exist_ok=True) _active_games: set[int] = set() # users with an in-progress interactive game -_active_heist: "HeistLobbyView | None" = None # server-wide singleton # heist global CD is persisted on the house record in PocketBase (see economy.get/set_heist_global_cd) _spam_tracker: dict[int, collections.deque] = {} # user_id -> deque of recent income-cmd timestamps _SPAM_WINDOW = 5.0 # seconds @@ -675,6 +679,12 @@ def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]: _reminder_tasks: dict[tuple[int, str], asyncio.Task] = {} +def _cancel_reminder_task(user_id: int, cmd: str) -> None: + task = _reminder_tasks.pop((user_id, cmd), None) + if task and not task.done(): + task.cancel() + + def _schedule_reminder(user_id: int, cmd: str, delay: datetime.timedelta) -> None: """DM the user when their cooldown expires. Replaces any existing task.""" async def _remind(): @@ -770,2968 +780,59 @@ async def _maybe_remind(user_id: int, cmd: str) -> None: _schedule_reminder(user_id, cmd, delay) -# --------------------------------------------------------------------------- -# /profile - tabbed profile view -# --------------------------------------------------------------------------- - -def _profile_main_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed: - exp = data.get("exp", 0) - level = economy.get_level(exp) - role_name = economy.level_role_name(level) - next_level = level + 1 - exp_this = economy.exp_for_level(level) - exp_next = economy.exp_for_level(next_level) - progress = exp - exp_this - needed = exp_next - exp_this - pct = progress / needed if needed > 0 else 1.0 - filled = int(pct * 12) - bar = "█" * filled + "░" * (12 - filled) - embed = discord.Embed( - title=S.PROFILE_UI["main_title"].format(name=target.display_name), - color=0xF4C430, - ) - embed.add_field(name=S.PROFILE_UI["f_balance"], value=_coin(data.get("balance", 0)), inline=True) - embed.add_field(name=S.PROFILE_UI["f_level"], value=S.PROFILE_UI["level_val"].format(level=level, role=role_name), inline=True) - streak = data.get("daily_streak", 0) - if streak: - embed.add_field(name=S.PROFILE_UI["f_streak"], value=S.BALANCE_UI["streak_val"].format(streak=streak), inline=True) - p_level = data.get("prestige_level", 0) - if p_level > 0: - p_pp = data.get("prestige_points", 0) - embed.add_field(name=S.PROFILE_UI["f_prestige"], value=S.PROFILE_UI["prestige_val"].format(level=p_level, pp=p_pp), inline=True) - jail_remaining = economy._is_jailed(data) - if jail_remaining: - embed.add_field(name=S.PROFILE_UI["f_jail"], value=_cd_ts(jail_remaining), inline=True) - embed.add_field( - name=S.PROFILE_UI["f_progress"].format(next=next_level), - value=S.PROFILE_UI["progress_bar"].format(bar=bar, done=progress, needed=needed), - inline=False, - ) - if level < 10: - embed.set_footer(text=S.PROFILE_UI["footer_t1"]) - elif level < 20: - embed.set_footer(text=S.PROFILE_UI["footer_t2"]) - else: - embed.set_footer(text=S.PROFILE_UI["footer_t3"]) - return embed - - -def _profile_items_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed: - embed = discord.Embed( - title=S.PROFILE_UI["items_title"].format(name=target.display_name), - color=0xF4C430, - ) - uses_map = data.get("item_uses", {}) - item_lines = [] - for i in data.get("items", []): - if i not in economy.SHOP: - continue - line = f"{economy.SHOP[i]['emoji']} **{economy.SHOP[i]['name']}**" - if i in uses_map: - u = uses_map[i] - line += S.BALANCE_UI["uses_one"].format(uses=u) if u == 1 else S.BALANCE_UI["uses_many"].format(uses=u) - item_lines.append(line) - embed.description = "\n".join(item_lines) if item_lines else S.PROFILE_UI["items_empty"] - return embed - - -def _profile_stats_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed: - def _s(key: str) -> int: - return data.get(key, 0) - embed = discord.Embed( - title=S.PROFILE_UI["stats_title"].format(name=target.display_name), - color=0x5865F2, - ) - embed.add_field( - name=S.STATS_UI["economy_field"], - value=S.STATS_UI["economy_val"].format(peak=_coin(_s("peak_balance")), earned=_coin(_s("lifetime_earned")), lost=_coin(_s("lifetime_lost"))), - inline=True, - ) - embed.add_field( - name=S.STATS_UI["work_field"], - value=S.STATS_UI["work_val"].format(work=_s("work_count"), beg=_s("beg_count")), - inline=True, - ) - embed.add_field(name="\u200b", value="\u200b", inline=False) - embed.add_field( - name=S.STATS_UI["gamble_field"], - value=S.STATS_UI["gamble_val"].format(wagered=_coin(_s("total_wagered")), win=_coin(_s("biggest_win")), loss=_coin(_s("biggest_loss")), jackpots=_s("slots_jackpots")), - inline=True, - ) - embed.add_field( - name=S.STATS_UI["crime_field"], - value=S.STATS_UI["crime_val"].format(crimes=_s("crimes_attempted"), succeeded=_s("crimes_succeeded"), heists=_s("heists_joined"), heists_won=_s("heists_won"), jailed=_s("times_jailed"), bail=_coin(_s("total_bail_paid"))), - inline=True, - ) - embed.add_field(name="\u200b", value="\u200b", inline=False) - embed.add_field( - name=S.STATS_UI["social_field"], - value=S.STATS_UI["social_val"].format(given=_coin(_s("total_given")), received=_coin(_s("total_received"))), - inline=True, - ) - embed.add_field( - name=S.STATS_UI["records_field"], - value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")), - inline=True, - ) - return embed - - -def _profile_fish_embed(target: discord.User | discord.Member, fish_res: dict) -> discord.Embed: - embed = discord.Embed( - title=S.PROFILE_UI["fish_title"].format(name=target.display_name), - color=0x5865F2, - ) - book: dict = fish_res["book"] - if not book: - embed.description = S.FISH_UI["book_empty"] - return embed - inv_counts: dict = fish_res.get("inv_counts", {}) - caught_count = fish_res["unique_caught"] - total = fish_res["total_species"] - lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)] - for fish_id, fish_data in economy.FISH_CATALOGUE.items(): - rarity = fish_data["rarity"] - emoji = S.FISH_RARITY_EMOJI[rarity] - rarity_name = S.FISH_RARITY_NAMES[rarity] - count = book.get(fish_id, 0) - if count > 0: - n_inv = inv_counts.get(fish_id, 0) - inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else "" - lines.append(S.FISH_UI["book_yes"].format(emoji=emoji, name=S.FISH_NAMES[fish_id], rarity=rarity_name, count=count, inv=inv_str)) - else: - lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name)) - embed.description = "\n".join(lines) - embed.set_footer(text=S.FISH_UI["book_footer"].format(page=1, total_pages=1, caught=caught_count, total=total)) - return embed - - -class ProfileView(discord.ui.View): - def __init__(self, target: discord.User | discord.Member, invoker_id: int, tab: str = "main"): - super().__init__(timeout=120) - self.target = target - self.invoker_id = invoker_id - self.tab = tab - self._rebuild() - - def _rebuild(self): - self.clear_items() - tabs = [ - ("main", S.PROFILE_UI["btn_profile"]), - ("items", S.PROFILE_UI["btn_items"]), - ("stats", S.PROFILE_UI["btn_stats"]), - ("fish", S.PROFILE_UI["btn_fish"]), - ] - for tab_id, label in tabs: - btn = discord.ui.Button( - label=label, - style=discord.ButtonStyle.primary if tab_id == self.tab else discord.ButtonStyle.secondary, - disabled=(tab_id == self.tab), - ) - btn.callback = self._make_cb(tab_id) - self.add_item(btn) - - def _make_cb(self, tab_id: str): - async def _cb(interaction: discord.Interaction): - if interaction.user.id != self.invoker_id: - await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) - return - self.tab = tab_id - self._rebuild() - await interaction.response.defer() - data = await economy.get_user(self.target.id) - if tab_id == "fish": - fish_res = await economy.do_fishbook(self.target.id) - embed = _profile_fish_embed(self.target, fish_res) - inv: list = data.get("fish_inventory") or [] - if inv and self.target.id == self.invoker_id: - total_value = sum(e.get("value", 0) for e in inv) - sell_btn = discord.ui.Button( - label=f"{S.FISH_UI['btn_sell']} ({len(inv)} kala · {total_value:,} {economy.COIN})", - style=discord.ButtonStyle.success, - row=1, - ) - sell_btn.callback = self._sell_fish_cb() - self.add_item(sell_btn) - elif tab_id == "items": - embed = _profile_items_embed(self.target, data) - elif tab_id == "stats": - embed = _profile_stats_embed(self.target, data) - else: - embed = _profile_main_embed(self.target, data) - await interaction.edit_original_response(embed=embed, view=self) - return _cb - - def _sell_fish_cb(self): - async def _cb(interaction: discord.Interaction): - if interaction.user.id != self.invoker_id: - await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) - return - await interaction.response.defer() - res = await economy.do_fish_sell(self.invoker_id) - self.tab = "fish" - self._rebuild() - fish_res = await economy.do_fishbook(self.target.id) - embed = _profile_fish_embed(self.target, fish_res) - sold_line = S.FISH_UI["inv_sold_all"].format(count=res.get("count", 0), coins=_coin(res.get("coins", 0)), balance=_coin(res.get("balance", 0))) - embed.description = f"{sold_line}\n\n{embed.description or ''}" - await interaction.edit_original_response(embed=embed, view=self) - return _cb - - -@tree.command(name="profile", description=S.CMD["profile"]) -@app_commands.describe(kasutaja=S.OPT["profile_kasutaja"]) -async def cmd_profile(interaction: discord.Interaction, kasutaja: discord.Member | None = None): - target = kasutaja or interaction.user - data = await economy.get_user(target.id) - embed = _profile_main_embed(target, data) - invoker_id = interaction.user.id - await interaction.response.send_message(embed=embed, view=ProfileView(target, invoker_id)) - if not kasutaja and interaction.guild: - member = interaction.guild.get_member(target.id) - if member: - asyncio.create_task(_ensure_level_role(member, economy.get_level(data.get("exp", 0)))) - - -def _balance_embed(user: discord.User | discord.Member, data: dict) -> discord.Embed: - embed = discord.Embed( - title=f"{economy.COIN} {user.display_name}", - color=0xF4C430, - ) - embed.add_field(name=S.BALANCE_UI["saldo"], value=_coin(data["balance"]), inline=True) - streak = data.get("daily_streak", 0) - if streak: - embed.add_field(name=S.BALANCE_UI["streak"], value=S.BALANCE_UI["streak_val"].format(streak=streak), inline=True) - # Jail status - jail_remaining = economy._is_jailed(data) - if jail_remaining: - embed.add_field(name=S.BALANCE_UI["jailed_until"], value=_cd_ts(jail_remaining), inline=True) - # Items with uses info - item_lines = [] - uses_map = data.get("item_uses", {}) - for i in data.get("items", []): - if i not in economy.SHOP: - continue - line = f"{economy.SHOP[i]['emoji']} {economy.SHOP[i]['name']}" - if i in uses_map: - u = uses_map[i] - line += S.BALANCE_UI["uses_one"].format(uses=u) if u == 1 else S.BALANCE_UI["uses_many"].format(uses=u) - item_lines.append(line) - if item_lines: - embed.add_field(name=S.BALANCE_UI["items"], value="\n".join(item_lines), inline=False) - return embed - - -@tree.command(name="balance", description=S.CMD["balance"]) -async def cmd_balance(interaction: discord.Interaction, kasutaja: discord.Member | None = None): - target = kasutaja or interaction.user - data = await economy.get_user(target.id) - await interaction.response.send_message(embed=_balance_embed(target, data)) - - -@tree.command(name="cooldowns", description=S.CMD["cooldowns"]) -async def cmd_cooldowns(interaction: discord.Interaction): - data = await economy.get_user(interaction.user.id) - now = datetime.datetime.now(datetime.timezone.utc) - items = set(data.get("items", [])) - - def _status(last_key: str, cd: datetime.timedelta) -> str: - raw = data.get(last_key) - if not raw: - return S.COOLDOWNS_UI["ready"] - last = economy._parse_dt(raw) - if last is None: - return S.COOLDOWNS_UI["ready"] - expires = last + cd - if expires <= now: - return S.COOLDOWNS_UI["ready"] - ts = int(expires.timestamp()) - return f"⏳ " - - work_cd = datetime.timedelta(minutes=40) if "monitor" in items else economy.COOLDOWNS["work"] - beg_cd = datetime.timedelta(minutes=3) if "hiirematt" in items else economy.COOLDOWNS["beg"] - daily_cd = datetime.timedelta(hours=18) if "korvaklapid" in items else economy.COOLDOWNS["daily"] - fish_cd = datetime.timedelta(seconds=90) if "ussipurk" in items else economy.COOLDOWNS["fish"] - - lines = [ - S.COOLDOWNS_UI["daily_line"].format(status=_status("last_daily", daily_cd), note=S.COOLDOWNS_UI["note_korvak"] if "korvaklapid" in items else ""), - S.COOLDOWNS_UI["work_line"].format(status=_status("last_work", work_cd), note=S.COOLDOWNS_UI["note_monitor"] if "monitor" in items else ""), - S.COOLDOWNS_UI["beg_line"].format(status=_status("last_beg", beg_cd), note=S.COOLDOWNS_UI["note_hiirematt"] if "hiirematt" in items else ""), - S.COOLDOWNS_UI["crime_line"].format(status=_status("last_crime", economy.COOLDOWNS["crime"])), - S.COOLDOWNS_UI["rob_line"].format(status=_status("last_rob", economy.COOLDOWNS["rob"])), - S.COOLDOWNS_UI["fish_line"].format(status=_status("last_fish", fish_cd), note=S.COOLDOWNS_UI["note_ussipurk"] if "ussipurk" in items else ""), - ] - - jailed = data.get("jailed_until") - if jailed: - jail_dt = datetime.datetime.fromisoformat(jailed) - if jail_dt > now: - ts = int(jail_dt.timestamp()) - lines.append(S.COOLDOWNS_UI["jailed"].format(ts=ts)) - else: - lines.append(S.COOLDOWNS_UI["jail_expired"]) - - embed = discord.Embed( - title=S.TITLE["cooldowns"], - description="\n".join(lines), - color=0x5865F2, - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - - -@tree.command(name="jailed", description=S.CMD["jailed"]) -@app_commands.guild_only() -async def cmd_jailed(interaction: discord.Interaction): - await interaction.response.defer() - jailed = await economy.do_get_jailed() - if not jailed: - embed = discord.Embed( - title=S.JAILED_UI["title"], - description=S.JAILED_UI["empty"], - color=0x57F287, - ) - await interaction.followup.send(embed=embed) - return - - now = datetime.datetime.now(datetime.timezone.utc) - lines = [] - for uid, remaining in jailed: - ts = int((now + remaining).timestamp()) - member = interaction.guild.get_member(uid) if interaction.guild else None - mention = member.mention if member else f"<@{uid}>" - lines.append(S.JAILED_UI["entry"].format(mention=mention, ts=ts)) - - plural = "" if len(jailed) == 1 else "i" - embed = discord.Embed( - title=S.JAILED_UI["title"], - description="\n".join(lines), - color=0xED4245, - ) - embed.set_footer(text=S.JAILED_UI["footer"].format(count=len(jailed), plural=plural)) - await interaction.followup.send(embed=embed) - - -@tree.command(name="rank", description=S.CMD["rank"]) -@app_commands.describe(kasutaja=S.OPT["rank_kasutaja"]) -async def cmd_rank(interaction: discord.Interaction, kasutaja: discord.Member | None = None): - target = kasutaja or interaction.user - data = await economy.get_user(target.id) - exp = data.get("exp", 0) - level = economy.get_level(exp) - role_name = economy.level_role_name(level) - next_level = level + 1 - exp_this = economy.exp_for_level(level) - exp_next = economy.exp_for_level(next_level) - progress = exp - exp_this - needed = exp_next - exp_this - pct = progress / needed if needed > 0 else 1.0 - filled = int(pct * 12) - bar = "█" * filled + "░" * (12 - filled) - embed = discord.Embed( - title=S.RANK_UI["title"].format(name=target.display_name, level=level), - color=0x5865F2, - ) - embed.add_field(name=S.RANK_UI["field_title"], value=f"**{role_name}**", inline=True) - embed.add_field(name=S.RANK_UI["field_exp"], value=str(exp), inline=True) - embed.add_field( - name=S.RANK_UI["field_progress"].format(next=next_level), - value=S.RANK_UI["progress_val"].format(bar=bar, progress=progress, needed=needed), - inline=False, - ) - p_level = data.get("prestige_level", 0) - p_pp = data.get("prestige_points", 0) - s_exp = data.get("season_total_exp", 0) - if p_level > 0 or s_exp > 0: - embed.add_field( - name="\u200b", - value=S.PRESTIGE_UI["rank_line"].format(level=p_level, pp=p_pp) + "\n" + S.PRESTIGE_UI["rank_season"].format(exp=s_exp), - inline=False, - ) - if level < 10: - embed.set_footer(text=S.RANK_UI["footer_t1"]) - elif level < 20: - embed.set_footer(text=S.RANK_UI["footer_t2"]) - else: - embed.set_footer(text=S.RANK_UI["footer_t3"]) - await interaction.response.send_message(embed=embed, ephemeral=True) - if not kasutaja and interaction.guild: - member = interaction.guild.get_member(target.id) - if member: - asyncio.create_task(_ensure_level_role(member, level)) - - -@tree.command(name="stats", description=S.CMD["stats"]) -@app_commands.describe(kasutaja=S.OPT["stats_kasutaja"]) -async def cmd_stats(interaction: discord.Interaction, kasutaja: discord.Member | None = None): - target = kasutaja or interaction.user - d = await economy.get_user(target.id) - - def _s(key: str) -> int: - return d.get(key, 0) - - embed = discord.Embed( - title=f"{S.TITLE['stats']} - {target.display_name}", - color=0x5865F2, - ) - embed.add_field( - name=S.STATS_UI["economy_field"], - value=S.STATS_UI["economy_val"].format(peak=_coin(_s("peak_balance")), earned=_coin(_s("lifetime_earned")), lost=_coin(_s("lifetime_lost"))), - inline=True, - ) - embed.add_field( - name=S.STATS_UI["work_field"], - value=S.STATS_UI["work_val"].format(work=_s("work_count"), beg=_s("beg_count")), - inline=True, - ) - embed.add_field(name="\u200b", value="\u200b", inline=False) - embed.add_field( - name=S.STATS_UI["gamble_field"], - value=S.STATS_UI["gamble_val"].format(wagered=_coin(_s("total_wagered")), win=_coin(_s("biggest_win")), loss=_coin(_s("biggest_loss")), jackpots=_s("slots_jackpots")), - inline=True, - ) - embed.add_field( - name=S.STATS_UI["crime_field"], - value=S.STATS_UI["crime_val"].format(crimes=_s("crimes_attempted"), succeeded=_s("crimes_succeeded"), heists=_s("heists_joined"), heists_won=_s("heists_won"), jailed=_s("times_jailed"), bail=_coin(_s("total_bail_paid"))), - inline=True, - ) - embed.add_field(name="\u200b", value="\u200b", inline=False) - embed.add_field( - name=S.STATS_UI["social_field"], - value=S.STATS_UI["social_val"].format(given=_coin(_s("total_given")), received=_coin(_s("total_received"))), - inline=True, - ) - embed.add_field( - name=S.STATS_UI["records_field"], - value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")), - inline=True, - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - - -@tree.command(name="daily", description=S.CMD["daily"]) -async def cmd_daily(interaction: discord.Interaction): - res = await economy.do_daily(interaction.user.id) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "cooldown": - await interaction.response.send_message( - S.CD_MSG["daily"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - - streak = res["streak"] - streak_str = f"🔥 {streak}p" + ( - " (+200%)" if res["streak_mult"] >= 3.0 else - " (+100%)" if res["streak_mult"] >= 2.0 else - " (+50%)" if res["streak_mult"] >= 1.5 else "" - ) - lines = [S.DAILY_UI["earned"].format(earned=_coin(res["earned"]))] - if res["interest"]: - lines.append(S.DAILY_UI["interest"].format(interest=_coin(res["interest"]))) - if res["vip"]: - lines.append(S.DAILY_UI["vip"]) - lines.append(S.DAILY_UI["footer"].format(streak_str=streak_str, balance=_coin(res["balance"]))) - - embed = discord.Embed(title=S.TITLE["daily"], description="\n".join(lines), color=0xF4C430) - await interaction.response.send_message(embed=embed) - asyncio.create_task(_maybe_remind(interaction.user.id, "daily")) - asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["daily"])) - - -@tree.command(name="work", description=S.CMD["work"]) -async def cmd_work(interaction: discord.Interaction): - if await _check_cmd_rate(interaction): - return - res = await economy.do_work(interaction.user.id) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "cooldown": - await interaction.response.send_message( - S.CD_MSG["work"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - elif res["reason"] == "jailed": - await interaction.response.send_message( - S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - - desc = S.WORK_UI["desc"].format(job=res["job"], earned=_coin(res["earned"])) - if res["lucky"]: - desc += S.WORK_UI["redbull"] - if res["hiir"]: - desc += S.WORK_UI["hiir"] - if res["laud"]: - desc += S.WORK_UI["laud"] - desc += S.WORK_UI["balance"].format(balance=_coin(res["balance"])) - embed = discord.Embed(title=S.TITLE["work"], description=desc, color=0x57F287) - await interaction.response.send_message(embed=embed) - asyncio.create_task(_maybe_remind(interaction.user.id, "work")) - asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["work"])) - - -@tree.command(name="beg", description=S.CMD["beg"]) -async def cmd_beg(interaction: discord.Interaction): - if await _check_cmd_rate(interaction): - return - res = await economy.do_beg(interaction.user.id) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "cooldown": - await interaction.response.send_message( - S.CD_MSG["beg"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - - if res["jailed"]: - title = "🔒 " + S.TITLE["beg"] - color = 0xE67E22 - else: - title = S.TITLE["beg"] - color = 0x99AAB5 - beg_lines = [S.BEG_UI["desc"].format(text=res["text"], earned=_coin(res["earned"]))] - if res["klaviatuur"]: - beg_lines.append(S.BEG_UI["klaviatuur"]) - beg_lines.append(S.BEG_UI["balance"].format(balance=_coin(res["balance"]))) - embed = discord.Embed(title=title, description="\n".join(beg_lines), color=color) - await interaction.response.send_message(embed=embed) - asyncio.create_task(_maybe_remind(interaction.user.id, "beg")) - asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["beg"])) - - -@tree.command(name="crime", description=S.CMD["crime"]) -async def cmd_crime(interaction: discord.Interaction): - if await _check_cmd_rate(interaction): - return - res = await economy.do_crime(interaction.user.id) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "cooldown": - await interaction.response.send_message( - S.CD_MSG["crime"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - elif res["reason"] == "jailed": - await interaction.response.send_message( - S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - - if res["success"]: - crime_lines = [S.CRIME_UI["win_desc"].format(text=res["text"], earned=_coin(res["earned"]))] - if res["mikrofon"]: - crime_lines.append(S.CRIME_UI["mikrofon"].lstrip("\n")) - crime_lines.append(S.CRIME_UI["balance"].lstrip("\n").format(balance=_coin(res["balance"]))) - embed = discord.Embed( - title=S.TITLE["crime_win"], - description="\n".join(crime_lines), - color=0x57F287, - ) - else: - jail_part = S.CRIME_UI["fail_jailed"].format(ts=_cd_ts(economy.JAIL_DURATION)) if res.get("jailed") else S.CRIME_UI["fail_shield"] - embed = discord.Embed( - title=S.TITLE["crime_fail"], - description=S.CRIME_UI["fail_base"].format(text=res["text"], fine=_coin(res["fine"])) + jail_part + S.CRIME_UI["balance"].format(balance=_coin(res["balance"])), - color=0xED4245, - ) - await interaction.response.send_message(embed=embed) - asyncio.create_task(_maybe_remind(interaction.user.id, "crime")) - if res["success"]: - asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["crime_win"])) - - -@tree.command(name="rob", description=S.CMD["rob"]) -async def cmd_rob(interaction: discord.Interaction, sihtmärk: discord.Member): - if await _check_cmd_rate(interaction): - return - if sihtmärk.id == interaction.user.id: - await interaction.response.send_message(S.ERR["rob_self"], ephemeral=True) - return - if sihtmärk.bot and (bot.user is None or sihtmärk.id != bot.user.id): - await interaction.response.send_message(S.ERR["rob_bot"], ephemeral=True) - return - if bot.user and sihtmärk.id == bot.user.id: - await interaction.response.send_message(S.ERR["rob_house_blocked"], ephemeral=True) - return - - res = await economy.do_rob(interaction.user.id, sihtmärk.id) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "cooldown": - await interaction.response.send_message( - S.CD_MSG["rob"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - elif res["reason"] == "jailed": - await interaction.response.send_message( - S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - elif res["reason"] == "broke": - await interaction.response.send_message( - S.ERR["rob_too_poor"].format(name=sihtmärk.display_name), ephemeral=True - ) - elif res["reason"] == "target_jailed": - await interaction.response.send_message( - S.ERR["rob_target_jailed"].format(name=sihtmärk.display_name), ephemeral=True - ) - return - - if res["success"]: - if res.get("jackpot"): - desc = S.ROB_UI["jackpot_desc"].format(stolen=_coin(res["stolen"]), balance=_coin(res["balance"])) - color = 0xF4C430 - else: - desc = S.ROB_UI["win_desc"].format(stolen=_coin(res["stolen"]), name=sihtmärk.display_name, balance=_coin(res["balance"])) - color = 0x57F287 - embed = discord.Embed(title=S.TITLE["rob_win"], description=desc, color=color) - elif res["reason"] == "valvur": - embed = discord.Embed( - title=S.TITLE["rob_anticheat"], - description=S.ROB_UI["anticheat_desc"].format(name=sihtmärk.display_name, fine=_coin(res["fine"])), - color=0xED4245, - ) - # Notify target if anticheat fully depleted - target_data = await economy.get_user(sihtmärk.id) - if "anticheat" not in target_data.get("items", []): - try: - await sihtmärk.send(S.ROB_UI["anticheat_worn"]) - except discord.Forbidden: - pass - else: - embed = discord.Embed( - title=S.TITLE["rob_fail"], - description=S.ROB_UI["fail_desc"].format(fine=_coin(res["fine"]), balance=_coin(res["balance"])), - color=0xED4245, - ) - await interaction.response.send_message(embed=embed) - asyncio.create_task(_maybe_remind(interaction.user.id, "rob")) - if res["success"]: - asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["rob_win"])) - try: - await sihtmärk.send(S.ROB_UI["victim_dm"].format( - robber=interaction.user.display_name, - stolen=_coin(res["stolen"]), - )) - except discord.Forbidden: - pass - - -# --------------------------------------------------------------------------- -# /heist - multiplayer group robbery of the house -# --------------------------------------------------------------------------- -_HEIST_JOIN_WINDOW = 300 # seconds players have to join -_HEIST_MIN_PLAYERS = 2 -_HEIST_GLOBAL_CD = 14400 # seconds between heist events server-wide (4h) -_HEIST_MAX_PLAYERS = 8 -_HEIST_BASE_CHANCE = 0.35 # 35% solo -_HEIST_CHANCE_STEP = 0.05 # +5% per extra player -_HEIST_MAX_CHANCE = 0.65 # cap at 65% - - -def _build_heist_story(participants: list[discord.Member], success: bool) -> list[str]: - """Return a list of story lines for the heist narrative reveal.""" - story = S.HEIST_STORY - leader = participants[0].display_name - member = participants[1].display_name if len(participants) > 1 else participants[0].display_name - if len(participants) == 1: - names = f"**{leader}**" - elif len(participants) == 2: - names = S.HEIST_UI["names_duo"].format(a=participants[0].display_name, b=participants[1].display_name) - elif len(participants) <= 4: - names = S.HEIST_UI["names_sep"].join(f"**{p.display_name}**" for p in participants) - else: - names = S.HEIST_UI["names_crew"].format(leader=participants[0].display_name) - - vehicle = random.choice(story["vehicles"]) - approach = random.choice(["sneaky", "loud"]) - non_leaders = participants[1:] if len(participants) > 1 else participants - - def fill(tmpl: str) -> str: - picked = random.choice(non_leaders).display_name - return tmpl.format( - leader=f"**{leader}**", member=f"**{picked}**", - names=names, vehicle=vehicle, - ) - - getaway_pool = "getaway_success" if success else "getaway_fail" - - return [ - fill(random.choice(story["arrival"])), - fill(random.choice(story[f"entry_{approach}"])), - fill(random.choice(story["inside"])), - fill(random.choice(story["vault"])), - fill(random.choice(story["vault_open"])), - fill(random.choice(story["police_inbound"])), - fill(random.choice(story[getaway_pool])), - fill(random.choice(story["escape_success" if success else "escape_fail"])), - ] - - -class HeistLobbyView(discord.ui.View): - def __init__(self, organizer: discord.Member, organizer_has_jellyfin: bool = False): - super().__init__(timeout=_HEIST_JOIN_WINDOW) - self.organizer = organizer - self.participants: list[discord.Member] = [organizer] - self.message: discord.Message | None = None - self.resolved = False - self.jellyfin_holders: int = 1 if organizer_has_jellyfin else 0 - - def _chance(self) -> float: - n = len(self.participants) - base = min(_HEIST_BASE_CHANCE + _HEIST_CHANCE_STEP * (n - 1), _HEIST_MAX_CHANCE) - jelly_bonus = 0.05 if self.jellyfin_holders > 0 else 0.0 - return min(base + jelly_bonus, _HEIST_MAX_CHANCE) - - def _lobby_embed(self) -> discord.Embed: - names = "\n".join(f"• {p.display_name}" for p in self.participants) - desc = S.HEIST_UI["lobby_desc"].format( - n=len(self.participants), max=_HEIST_MAX_PLAYERS, - names=names, chance=int(self._chance() * 100), - ts=int(self._timeout_expiry()), - ) - return discord.Embed(title=S.TITLE["heist_lobby"], description=desc, color=0xE67E22) - - def _timeout_expiry(self) -> float: - import time - return time.time() + (self.timeout or 0) - - @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": - 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.CD_MSG["heist"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - self.participants.append(interaction.user) - _active_games.add(interaction.user.id) - joiner_data = await economy.get_user(interaction.user.id) - if "jellyfin" in joiner_data.get("items", []): - self.jellyfin_holders += 1 - await interaction.response.edit_message(embed=self._lobby_embed()) - - @discord.ui.button(label=S.HEIST_UI["btn_start"], style=discord.ButtonStyle.success) - async def start_now(self, interaction: discord.Interaction, _: discord.ui.Button): - if interaction.user.id != self.organizer.id: - await interaction.response.send_message(S.HEIST_UI["only_organizer"], ephemeral=True) - return - if len(self.participants) < _HEIST_MIN_PLAYERS: - await interaction.response.send_message( - S.ERR["heist_min_players"].format(min=_HEIST_MIN_PLAYERS), ephemeral=True - ) - return - await self._resolve(interaction) - - async def _resolve(self, interaction: discord.Interaction | None = None) -> None: - global _active_heist - if self.resolved: - return - self.resolved = True - _active_heist = None - self.stop() - self.clear_items() - - for p in self.participants: - _active_games.discard(p.id) - - n = len(self.participants) - channel = (interaction.channel if interaction - else self.message.channel if self.message else None) - - if n < _HEIST_MIN_PLAYERS: - embed = discord.Embed( - title=S.TITLE["heist_cancel"], - description=S.HEIST_UI["cancel_desc"].format(min=_HEIST_MIN_PLAYERS), - color=0x99AAB5, - ) - if interaction and not interaction.response.is_done(): - await interaction.response.edit_message(embed=embed, view=self) - elif self.message: - try: - await self.message.edit(embed=embed, view=self) - except discord.HTTPException: - pass - return - - # Pre-roll outcome so story ending matches result - success = random.random() < self._chance() - story_lines = _build_heist_story(self.participants, success) - - # Close lobby message - remove buttons, mark as started - lobby_done = discord.Embed( - title=S.HEIST_UI["started_title"], - description=S.HEIST_UI["started_desc"].format(n=n), - color=0x99AAB5, - ) - if interaction and not interaction.response.is_done(): - await interaction.response.edit_message(embed=lobby_done, view=self) - elif self.message: - try: - await self.message.edit(embed=lobby_done, view=self) - except discord.HTTPException: - pass - - # Send story message and reveal line by line - if channel: - story_embed = discord.Embed(title=S.HEIST_UI["story_title"], description="", color=0xE67E22) - story_msg = await channel.send(embed=story_embed) - accumulated = "" - for i, line in enumerate(story_lines): - await asyncio.sleep(random.uniform(3.0, 4.5)) - accumulated += ("\n\n" if i > 0 else "") + line - story_embed.description = accumulated - try: - await story_msg.edit(embed=story_embed) - except discord.HTTPException: - pass - await asyncio.sleep(2.0) - - # Apply economy changes - res = await economy.do_heist_resolve([p.id for p in self.participants], success) - payout_each = res["payout_each"] - names_str = "\n".join(f"• {p.display_name}" for p in self.participants) - guild = (interaction.guild if interaction - else self.message.guild if self.message else None) - - if success: - result_desc = S.HEIST_UI["win_desc"].format(names=names_str, payout=_coin(payout_each)) - result_embed = discord.Embed( - title=S.TITLE["heist_win"], description=result_desc, color=0x57F287 - ) - for p in self.participants: - exp_res = await economy.award_exp(p.id, economy.EXP_REWARDS["heist_win"]) - if exp_res["old_level"] != exp_res["new_level"] and guild: - gm = guild.get_member(p.id) - if gm: - asyncio.create_task(_ensure_level_role(gm, exp_res["new_level"])) - else: - result_desc = S.HEIST_UI["fail_desc"].format(names=names_str) - result_embed = discord.Embed( - title=S.TITLE["heist_fail"], description=result_desc, color=0xED4245 - ) - - # Set global server cooldown (persisted to PocketBase via house record) - await economy.set_heist_global_cd(time.time() + _HEIST_GLOBAL_CD) - - # Post result as a NEW message so it appears at the bottom of the channel - if channel: - await channel.send(embed=result_embed) - elif self.message: - try: - await self.message.channel.send(embed=result_embed) - except discord.HTTPException: - pass - - async def on_timeout(self) -> None: - await self._resolve() - - -@tree.command(name="heist", description=S.CMD["heist"]) -@app_commands.guild_only() -async def cmd_heist(interaction: discord.Interaction): - global _active_heist - 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 - - organizer_data = await economy.get_user(interaction.user.id) - view = HeistLobbyView(interaction.user, "jellyfin" in organizer_data.get("items", [])) - _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() - - -# --------------------------------------------------------------------------- -# /jailbreak - Monopoly-style dice escape -# --------------------------------------------------------------------------- -_DICE_EMOJI = [ - "<:TipiYKS:1483103190491856916>", - "<:TipiKAKS:1483103215841972404>", - "<:TipiKOLM:1483103217846980781>", - "<:TipiNELI:1483103237585240114>", - "<:TipiVIIS:1483103239036469289>", - "<:TipiKUUS:1483103253163020348>", -] - - -class JailbreakView(discord.ui.View): - MAX_TRIES = 3 - - def __init__(self, user_id: int): - super().__init__(timeout=120) - self.user_id = user_id - self.tries = 0 - self._rolling = False - self._add_roll_btn() - - def _add_roll_btn(self): - self.clear_items() - btn = discord.ui.Button( - label=S.JAILBREAK_UI["btn_roll"].format(try_=self.tries + 1, max=self.MAX_TRIES), - style=discord.ButtonStyle.primary, - ) - btn.callback = self._on_roll - self.add_item(btn) - - async def _on_roll(self, interaction: discord.Interaction): - if interaction.user.id != self.user_id: - await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) - return - if self._rolling: - await interaction.response.defer() - return - self._rolling = True - - # Show rolling animation immediately - self.clear_items() - rolling_embed = discord.Embed( - title=S.TITLE["jailbreak"], - description=S.JAILBREAK_UI["rolling_desc"], - color=0xF4C430, - ) - await interaction.response.edit_message(embed=rolling_embed, view=self) - - # Roll both dice, then reveal after delay - d1 = random.randint(1, 6) - d2 = random.randint(1, 6) - e1, e2 = _DICE_EMOJI[d1 - 1], _DICE_EMOJI[d2 - 1] - double = d1 == d2 - self.tries += 1 - tries_left = self.MAX_TRIES - self.tries - - await asyncio.sleep(1.5) - self._rolling = False - - if double: - await economy.do_jail_free(self.user_id) - self.stop() - embed = discord.Embed( - title=S.TITLE["jailbreak_free"], - description=S.JAILBREAK_UI["free_desc"].format(d1=e1, d2=e2), - color=0x57F287, - ) - await interaction.edit_original_response(embed=embed, view=self) - elif tries_left == 0: - self.stop() - user_data = await economy.get_user(self.user_id) - bal = user_data["balance"] - if bal >= economy.MIN_BAIL: - min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) - max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) - desc = S.JAILBREAK_UI["fail_bail_offer"].format( - d1=e1, d2=e2, min=_coin(min_fine), max=_coin(max_fine), bal=_coin(bal) - ) - embed = discord.Embed(title=S.TITLE["jailbreak_fail"], description=desc, color=0xED4245) - await interaction.edit_original_response(embed=embed, view=BailView(self.user_id)) - else: - embed = discord.Embed( - title=S.TITLE["jailbreak_fail"], - description=S.JAILBREAK_UI["fail_broke_desc"].format(d1=e1, d2=e2, balance=_coin(bal)), - color=0xED4245, - ) - await interaction.edit_original_response(embed=embed, view=None) - else: - self._add_roll_btn() - embed = discord.Embed( - title=S.TITLE["jailbreak_miss"].format(tries=self.tries, max=self.MAX_TRIES), - description=S.JAILBREAK_UI["miss_desc"].format(d1=e1, d2=e2, left=tries_left), - color=0xF4C430, - ) - await interaction.edit_original_response(embed=embed, view=self) - - -class BailView(discord.ui.View): - def __init__(self, user_id: int): - super().__init__(timeout=60) - self.user_id = user_id - - @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 - res = await economy.do_bail(self.user_id) - self.clear_items() - self.stop() - if not res["ok"] and res.get("reason") == "broke": - embed = discord.Embed( - title=S.TITLE["jailbreak_bail"], - description=S.JAILBREAK_UI["bail_broke_desc"].format(min=_coin(economy.MIN_BAIL), balance=_coin(res["balance"])), - color=0xED4245, - ) - else: - embed = discord.Embed( - title=S.TITLE["jailbreak_bail"], - description=S.JAILBREAK_UI["bail_paid_desc"].format(fine=_coin(res["fine"]), balance=_coin(res["balance"])), - color=0x57F287, - ) - await interaction.response.edit_message(embed=embed, view=self) - - -@tree.command(name="jailbreak", description=S.CMD["jailbreak"]) -async def cmd_jailbreak(interaction: discord.Interaction): - user_data = await economy.get_user(interaction.user.id) - remaining = economy._is_jailed(user_data) - if not remaining: - await interaction.response.send_message(S.ERR["not_jailed"], ephemeral=True) - return - - if user_data.get("jailbreak_used", False): - bal = user_data["balance"] - min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) - max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) - if bal >= economy.MIN_BAIL: - desc = S.JAILBREAK_UI["already_bail"].format(min=_coin(min_fine), max=_coin(max_fine), bal=_coin(bal), ts=_cd_ts(remaining)) - await interaction.response.send_message( - embed=discord.Embed(title=S.TITLE["jailbreak_bail"], description=desc, color=0xED4245), - view=BailView(interaction.user.id), ephemeral=True, - ) - else: - desc = S.JAILBREAK_UI["already_broke"].format(min=_coin(economy.MIN_BAIL), bal=_coin(bal), ts=_cd_ts(remaining)) - await interaction.response.send_message( - embed=discord.Embed(title=S.TITLE["jailbreak_bail"], description=desc, color=0xED4245), - ephemeral=True, - ) - return - - await economy.set_jailbreak_used(interaction.user.id) - embed = discord.Embed( - title=S.TITLE["jailbreak"], - description=S.JAILBREAK_UI["intro_desc"].format(ts=_cd_ts(remaining), tries=JailbreakView.MAX_TRIES), - color=0xF4C430, - ) - await interaction.response.send_message( - embed=embed, view=JailbreakView(interaction.user.id), ephemeral=True - ) - - -# --------------------------------------------------------------------------- -# /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 <= 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 - - # ── Spin animation ──────────────────────────────────────────────────── - 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) - - # ── Final result embed ──────────────────────────────────────────────── - 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) - - -@tree.command(name="give", description=S.CMD["give"]) -@app_commands.describe(kasutaja=S.OPT["give_kasutaja"], summa=S.OPT["give_summa"]) -async def cmd_give(interaction: discord.Interaction, kasutaja: discord.Member, summa: str): - _data = await economy.get_user(interaction.user.id) - summa_int, _err = _parse_amount(summa, _data["balance"]) - if _err: - await interaction.response.send_message(_err, ephemeral=True) - return - if summa_int <= 0: - await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) - return - if kasutaja.id == interaction.user.id: - await interaction.response.send_message(S.ERR["give_self"], ephemeral=True) - return - if kasutaja.bot: - await interaction.response.send_message(S.ERR["give_bot"], ephemeral=True) - return - - res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int) - 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.ERR["give_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 - - embed = discord.Embed( - title=f"{economy.COIN} {S.TITLE['give']}", - description=S.GIVE_UI["desc"].format(giver=interaction.user.display_name, amount=_coin(summa_int), receiver=kasutaja.display_name), - color=0xF4C430, - ) - await interaction.response.send_message(embed=embed) - - -class LeaderboardView(discord.ui.View): - PER_PAGE = 10 - - def __init__(self, data: dict, guild: discord.Guild | None, bot_user: discord.ClientUser | None): - super().__init__(timeout=120) - self.data = data - self.guild = guild - self.bot_user = bot_user - self.page = 0 - self.mode = "coins" - self.max_page = 0 - self._update_buttons() - - def _current_list(self) -> list: - return self.data.get(self.mode, []) - - def _update_buttons(self): - current = self._current_list() - self.max_page = max(0, (len(current) - 1) // self.PER_PAGE) if current else 0 - self.prev_btn.disabled = self.page == 0 - self.next_btn.disabled = self.page >= self.max_page - for m, btn in [ - ("coins", self.coins_btn), - ("exp", self.exp_btn), - ("season", self.season_btn), - ("prestige",self.prestige_btn), - ("wagered", self.wagered_btn), - ("fish", self.fish_btn), - ]: - btn.style = discord.ButtonStyle.primary if m == self.mode else discord.ButtonStyle.secondary - - def _name(self, uid: str, highlight_uid: int | None = None) -> str: - if self.guild: - member = self.guild.get_member(int(uid)) - name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) - else: - name = S.LEADERBOARD_UI["unknown_user"].format(uid=uid) - if highlight_uid and int(uid) == highlight_uid: - name = f"**› {name} ‹**" - return name - - def _make_embed(self, highlight_uid: int | None = None) -> discord.Embed: - title_map = { - "coins": f"{economy.COIN} {S.TITLE['leaderboard_coins']}", - "exp": S.TITLE["leaderboard_exp"], - "season": S.TITLE["leaderboard_season"], - "prestige": S.TITLE["leaderboard_prestige"], - "wagered": S.TITLE["leaderboard_wagered"], - "fish": S.TITLE["leaderboard_fish"], - } - color_map = {"coins": 0xF4C430, "wagered": 0xED4245, "fish": 0x57F287} - embed = discord.Embed(title=title_map.get(self.mode, "Edetabel"), color=color_map.get(self.mode, 0x5865F2)) - lines = [] - - if self.mode == "coins" and self.page == 0 and self.data.get("house_entry"): - _, bal = self.data["house_entry"] - house_name = self.bot_user.display_name if self.bot_user else "TipiBOT" - lines.append(S.LEADERBOARD_UI["house_entry"].format(name=house_name, balance=_coin(bal))) - lines.append("") - - start = self.page * self.PER_PAGE - medals = ["🥇", "🥈", "🥉"] - current = self._current_list() - slice_ = current[start:start + self.PER_PAGE] - - if not slice_: - lines.append(S.LEADERBOARD_UI["no_entries"]) - else: - for i, entry in enumerate(slice_): - rank = start + i - uid = entry[0] - prefix = medals[rank] if rank < 3 else f"**{rank + 1}.**" - name = self._name(uid, highlight_uid) - if self.mode == "coins": - lines.append(f"{prefix} {name} - {_coin(entry[1])}") - elif self.mode == "exp": - lines.append(S.LEADERBOARD_UI["exp_entry"].format(prefix=prefix, name=name, exp=entry[1], level=entry[2])) - elif self.mode == "season": - lines.append(S.LEADERBOARD_UI["season_entry"].format(prefix=prefix, name=name, exp=entry[1], prestige=entry[2])) - elif self.mode == "prestige": - lines.append(S.LEADERBOARD_UI["prestige_entry"].format(prefix=prefix, name=name, prestige=entry[1], pp=entry[2])) - elif self.mode == "wagered": - lines.append(S.LEADERBOARD_UI["wagered_entry"].format(prefix=prefix, name=name, wagered=_coin(entry[1]))) - elif self.mode == "fish": - lines.append(S.LEADERBOARD_UI["fish_entry"].format(prefix=prefix, name=name, caught=entry[1])) - - total = self.max_page + 1 - embed.description = "\n".join(lines) - embed.set_footer(text=S.LEADERBOARD_UI["footer"].format(page=self.page + 1, total=total, count=len(current))) - return embed - - @discord.ui.button(label="◄", style=discord.ButtonStyle.secondary, row=0) - async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - self.page -= 1 - self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(), view=self) - - @discord.ui.button(label="►", style=discord.ButtonStyle.secondary, row=0) - async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - self.page += 1 - self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(), view=self) - - @discord.ui.button(label=S.LEADERBOARD_UI["btn_find_me"], style=discord.ButtonStyle.secondary, row=0) - async def find_me_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - uid = interaction.user.id - for i, entry in enumerate(self._current_list()): - if int(entry[0]) == uid: - self.page = i // self.PER_PAGE - self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(highlight_uid=uid), view=self) - return - await interaction.response.send_message(S.ERR["not_in_leaderboard"], ephemeral=True) - - @discord.ui.button(label=S.LEADERBOARD_UI["btn_coins"], style=discord.ButtonStyle.primary, row=1) - async def coins_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - self.mode = "coins"; self.page = 0; self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(), view=self) - - @discord.ui.button(label=S.LEADERBOARD_UI["btn_exp"], style=discord.ButtonStyle.secondary, row=1) - async def exp_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - self.mode = "exp"; self.page = 0; self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(), view=self) - - @discord.ui.button(label=S.LEADERBOARD_UI["btn_season"], style=discord.ButtonStyle.secondary, row=1) - async def season_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - self.mode = "season"; self.page = 0; self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(), view=self) - - @discord.ui.button(label=S.LEADERBOARD_UI["btn_prestige"], style=discord.ButtonStyle.secondary, row=1) - async def prestige_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - self.mode = "prestige"; self.page = 0; self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(), view=self) - - @discord.ui.button(label=S.LEADERBOARD_UI["btn_wagered"], style=discord.ButtonStyle.secondary, row=1) - async def wagered_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - self.mode = "wagered"; self.page = 0; self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(), view=self) - - @discord.ui.button(label=S.LEADERBOARD_UI["btn_fish"], style=discord.ButtonStyle.secondary, row=2) - async def fish_btn(self, interaction: discord.Interaction, button: discord.ui.Button): - self.mode = "fish"; self.page = 0; self._update_buttons() - await interaction.response.edit_message(embed=self._make_embed(), view=self) - - async def on_timeout(self): - for child in self.children: - child.disabled = True - - -@tree.command(name="leaderboard", description=S.CMD["leaderboard"]) -async def cmd_leaderboard(interaction: discord.Interaction): - await interaction.response.defer() - coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather( - economy.get_leaderboard(top_n=None), - economy.get_leaderboard_exp(top_n=None), - economy.get_leaderboard_season_exp(top_n=None), - economy.get_leaderboard_prestige(top_n=None), - economy.get_leaderboard_wagered(top_n=None), - economy.get_leaderboard_fish(top_n=None), - ) - - house_entry = None - regular = [] - bot_id = bot.user.id if bot.user else None - for uid, bal in coins_raw: - if bot_id and int(uid) == bot_id: - house_entry = (uid, bal) - else: - regular.append((uid, bal)) - - def _no_bot(entries: list) -> list: - return [e for e in entries if not (bot_id and int(e[0]) == bot_id)] - - data = { - "coins": regular, - "exp": _no_bot(exp_raw), - "season": _no_bot(season_raw), - "prestige": _no_bot(prestige_raw), - "wagered": _no_bot(wagered_raw), - "fish": _no_bot(fish_raw), - "house_entry": house_entry, - } - view = LeaderboardView(data, interaction.guild, bot.user) - await interaction.followup.send(embed=view._make_embed(), view=view) - - -def _shop_embed(tier: int, user_data: dict) -> discord.Embed: - owned = set(user_data.get("items", [])) - item_uses = user_data.get("item_uses", {}) - _tier_names = {1: S.SHOP_UI["tier_1"], 2: S.SHOP_UI["tier_2"], 3: S.SHOP_UI["tier_3"]} - embed = discord.Embed( - title=f"{economy.COIN} TipiBOTi pood · {_tier_names[tier]}", - description=S.SHOP_UI["desc"].format(bal=_coin(user_data["balance"])), - color=[0x57F287, 0xF4C430, 0xED4245][tier - 1], - ) - for item_id in sorted(economy.SHOP_TIERS[tier], key=lambda k: economy.SHOP[k]["cost"]): - item = economy.SHOP[item_id] - anticheat_uses = item_uses.get("anticheat", 0) if item_id == "anticheat" else 0 - min_lvl = economy.SHOP_LEVEL_REQ.get(item_id, 0) - user_lvl = economy.get_level(user_data.get("exp", 0)) - if item_id in owned and not (item_id == "anticheat" and anticheat_uses <= 0): - if item_id == "anticheat": - _key = "owned_uses_1" if anticheat_uses == 1 else "owned_uses_n" - status = S.SHOP_UI[_key].format(uses=anticheat_uses) - else: - status = S.SHOP_UI["owned"] - elif min_lvl > 0 and user_lvl < min_lvl: - status = S.SHOP_UI["locked"].format(min_lvl=min_lvl, user_lvl=user_lvl) - else: - status = f"{item['cost']} {economy.COIN}" - embed.add_field( - name=f"{item['emoji']} {item['name']} · {status}", - value=item["description"], - inline=False, - ) - return embed - - -class ShopView(discord.ui.View): - def __init__(self, user_data: dict, tier: int = 1): - super().__init__(timeout=120) - self._user_data = user_data - self._tier = tier - self._update_buttons() - - def _update_buttons(self): - self.clear_items() - for t, label in [(1, S.SHOP_BTN[1]), (2, S.SHOP_BTN[2]), (3, S.SHOP_BTN[3])]: - btn = discord.ui.Button( - label=label, - style=discord.ButtonStyle.primary if t == self._tier else discord.ButtonStyle.secondary, - custom_id=f"shop_tier_{t}", - ) - btn.callback = self._make_callback(t) - self.add_item(btn) - - def _make_callback(self, tier: int): - async def callback(interaction: discord.Interaction): - self._tier = tier - self._update_buttons() - self._user_data = await economy.get_user(interaction.user.id) - await interaction.response.edit_message( - embed=_shop_embed(self._tier, self._user_data), view=self - ) - return callback - - -@tree.command(name="shop", description=S.CMD["shop"]) -async def cmd_shop(interaction: discord.Interaction): - data = await economy.get_user(interaction.user.id) - await interaction.response.send_message( - embed=_shop_embed(1, data), view=ShopView(data, tier=1), ephemeral=True - ) - - -@tree.command(name="buy", description=S.CMD["buy"]) -@app_commands.describe(ese=S.OPT["buy_ese"]) -@app_commands.choices(ese=[ - app_commands.Choice(name=f"{v['name']} ({v['cost']} TipiCOINi)", value=k) - for k, v in economy.SHOP.items() -]) -async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]): - res = await economy.do_buy(interaction.user.id, ese.value) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "owned": - await interaction.response.send_message(S.ERR["item_owned"], ephemeral=True) - elif res["reason"] == "level_required": - await interaction.response.send_message( - S.ERR["item_level_req"].format(min_level=res["min_level"], user_level=res["user_level"]), - ephemeral=True, - ) - elif res["reason"] == "insufficient": - await interaction.response.send_message( - S.ERR["broke_need"].format(need=_coin(res["need"])), ephemeral=True - ) - else: - await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True) - return - - item = res["item"] - embed = discord.Embed( - title=S.BUY_UI["title"].format(emoji=item["emoji"], name=item["name"]), - description=S.BUY_UI["desc"].format(description=item["description"], balance=_coin(res["balance"])), - color=0x57F287, - ) - await interaction.response.send_message(embed=embed) - - -# --------------------------------------------------------------------------- -# Rock Paper Scissors (vs Bot OR PvP) -# --------------------------------------------------------------------------- -_RPS_CHOICES = S.RPS_CHOICES -_RPS_BEATS = {"🪨": "✂️", "📄": "🪨", "✂️": "📄"} - - -# ── Single-player (vs bot) ────────────────────────────────────────────────── -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 - - -# ── PvP ───────────────────────────────────────────────────────────────────── -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 = f"❌ {self.player_a.display_name} võitis." - else: - winner, color = "b", 0xED4245 - result_a = f"❌ {self.player_b.display_name} võitis." - 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 < 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 <= 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 - - # ── Animated reveal ──────────────────────────────────────────────────── - 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) - - # ── Final verdict ───────────────────────────────────────────────────── - 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 # original per-hand 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 <= 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 = [] - - # ── Animated deal: player, dealer, player, dealer ───────────────────── - 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) - - # ── Immediate blackjack check ───────────────────────────────────────── - if _bj_is_blackjack(player_hand): - # Flip dealer card before resolving so player can see both hands - 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 - - # ── Normal game ─────────────────────────────────────────────────────── - 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, - ) - - -# --------------------------------------------------------------------------- -# /request - crowdfunding -# --------------------------------------------------------------------------- -class FundModal(discord.ui.Modal): - summa = discord.ui.TextInput( - label=S.REQUEST_UI["modal_label"], - min_length=1, - max_length=10, - ) - - def __init__(self, view: "RequestView"): - super().__init__(title=S.REQUEST_UI["modal_title"]) - self._view = view - self.summa.placeholder = f"1 - {view.remaining}" - - async def on_submit(self, interaction: discord.Interaction): - amount, _err = _parse_amount(self.summa.value, 0) - if _err or amount is None: - await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True) - return - if amount <= 0 or amount > self._view.remaining: - await interaction.response.send_message( - S.ERR["fund_range"].format(max=self._view.remaining), ephemeral=True - ) - return - - res = await economy.do_give(interaction.user.id, self._view.requester.id, amount) - if not res["ok"]: - data = await economy.get_user(interaction.user.id) - await interaction.response.send_message( - S.ERR["broke"].format(bal=_coin(data["balance"])), ephemeral=True - ) - return - - self._view.remaining -= amount - funded_line = S.REQUEST_UI["funded_line"].format(name=interaction.user.display_name, amount=_coin(amount)) - if self._view.remaining <= 0: - self._view.fund_btn.disabled = True - self._view.fund_btn.label = S.REQUEST_UI["btn_funded"] - self._view.fund_btn.style = discord.ButtonStyle.secondary - self._view.stop() - funded_line += S.REQUEST_UI["funded_full"] - else: - self._view.fund_btn.label = S.REQUEST_UI["btn_fund_remaining"].format(remaining=self._view.remaining) - funded_line += S.REQUEST_UI["funded_partial"].format(remaining=_coin(self._view.remaining)) - - await interaction.response.send_message(funded_line) - if self._view.message: - await self._view.message.edit(view=self._view) - - -class RequestView(discord.ui.View): - def __init__(self, requester: discord.Member, amount: int, target: discord.Member | None): - super().__init__(timeout=300) - self.requester = requester - self.remaining = amount - self.target = target - self.message: discord.Message | None = None - self.fund_btn = discord.ui.Button(label=S.REQUEST_UI["btn_fund"], style=discord.ButtonStyle.success) - self.fund_btn.callback = self._fund - self.add_item(self.fund_btn) - - async def _fund(self, interaction: discord.Interaction): - if interaction.user.id == self.requester.id: - await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True) - return - if self.target and interaction.user.id != self.target.id: - await interaction.response.send_message( - S.ERR["request_targeted"].format(name=self.target.display_name), ephemeral=True - ) - return - await interaction.response.send_modal(FundModal(self)) - - async def on_timeout(self): - for item in self.children: - item.disabled = True - - -_MAX_REQUEST = 1_000_000 - - -@tree.command(name="request", description=S.CMD["request"]) -@app_commands.describe( - summa=S.OPT["request_summa"], - põhjus=S.OPT["request_põhjus"], - sihtmärk=S.OPT["request_sihtmärk"], +register_economy_support_commands( + tree, + parse_amount=_parse_amount, + coin=_coin, + cancel_reminder_task=_cancel_reminder_task, ) -async def cmd_request( - interaction: discord.Interaction, - summa: str, - põhjus: str, - sihtmärk: discord.Member | None = None, -): - summa_int, _err = _parse_amount(summa, 0) - if _err or summa_int is None: - await interaction.response.send_message(_err or S.ERR["invalid_amount"], ephemeral=True) - return - if summa_int <= 0: - await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) - return - if summa_int > _MAX_REQUEST: - await interaction.response.send_message( - S.ERR["fund_range"].format(max=_coin(_MAX_REQUEST)), ephemeral=True - ) - return - summa = summa_int - if sihtmärk and sihtmärk.id == interaction.user.id: - await interaction.response.send_message(S.ERR["request_self"], ephemeral=True) - return - if sihtmärk and sihtmärk.bot: - await interaction.response.send_message(S.ERR["request_bot"], ephemeral=True) - return - audience = S.REQUEST_UI["audience_targeted"].format(name=sihtmärk.display_name) if sihtmärk else S.REQUEST_UI["audience_all"] - embed = discord.Embed( - title=S.TITLE["request"], - description=S.REQUEST_UI["desc"].format(requester=interaction.user.display_name, amount=_coin(summa), reason=põhjus, audience=audience), - color=0xF4C430, - ) - embed.set_footer(text=S.REQUEST_UI["footer"]) - view = RequestView(interaction.user, summa, sihtmärk) - await interaction.response.send_message(embed=embed, view=view) - view.message = await interaction.original_response() +register_economy_profile_commands( + tree, + coin=_coin, + cd_ts=_cd_ts, + ensure_level_role=_ensure_level_role, +) -# --------------------------------------------------------------------------- -# /reminders -# --------------------------------------------------------------------------- -class RemindersSelect(discord.ui.Select): - def __init__(self, user_id: int, current: list[str]): - self.user_id = user_id - options = [ - discord.SelectOption( - label=label, - description=desc, - value=cmd, - default=cmd in current, - ) - for cmd, label, desc in S.REMINDER_OPTS - ] - super().__init__( - placeholder=S.REMINDERS_UI["select_placeholder"], - options=options, - min_values=0, - max_values=len(S.REMINDER_OPTS), - ) +register_economy_income_commands( + tree, + bot, + coin=_coin, + cd_ts=_cd_ts, + check_cmd_rate=_check_cmd_rate, + maybe_remind=_maybe_remind, + award_exp=_award_exp, +) - async def callback(self, interaction: discord.Interaction): - if interaction.user.id != self.user_id: - await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) - return - await economy.do_set_reminders(self.user_id, self.values) - enabled = set(self.values) - for cmd in [opt[0] for opt in S.REMINDER_OPTS]: - if cmd not in enabled: - task = _reminder_tasks.pop((self.user_id, cmd), None) - if task and not task.done(): - task.cancel() - if self.values: - names = " ".join(f"`/{v}`" for v in self.values) - msg = S.REMINDERS_UI["saved_on"].format(names=names) - else: - msg = S.REMINDERS_UI["saved_off"] - await interaction.response.send_message(msg, ephemeral=True) +register_economy_fish_commands( + tree, + coin=_coin, + cd_ts=_cd_ts, + check_cmd_rate=_check_cmd_rate, + maybe_remind=_maybe_remind, + award_exp=_award_exp, + active_games=_active_games, +) +register_economy_games_commands( + tree, + coin=_coin, + cd_ts=_cd_ts, + parse_amount=_parse_amount, + gamble_cd=_gamble_cd, + award_exp=_award_exp, + active_games=_active_games, +) -class RemindersView(discord.ui.View): - def __init__(self, user_id: int, current: list[str]): - super().__init__(timeout=60) - self.add_item(RemindersSelect(user_id, current)) - - -@tree.command(name="reminders", description=S.CMD["reminders"]) -async def cmd_reminders(interaction: discord.Interaction): - user_data = await economy.get_user(interaction.user.id) - current = user_data.get("reminders", []) - if current: - status = " ".join(f"`/{c}`" for c in current) - desc = S.REMINDERS_UI["desc_active"].format(status=status) - else: - desc = S.REMINDERS_UI["desc_none"] - embed = discord.Embed( - title=S.TITLE["reminders"], - description=desc, - color=0x5865F2, - ) - embed.set_footer(text=S.REMINDERS_UI["footer"]) - await interaction.response.send_message(embed=embed, view=RemindersView(interaction.user.id, current), ephemeral=True) - - -# --------------------------------------------------------------------------- -# /fish - fishing minigame -# --------------------------------------------------------------------------- -class FishCatchView(discord.ui.View): - """Shown after a successful pull - lets user sell or keep the fish.""" - - def __init__(self, user_id: int, res: dict, fish_id: str, weight: int): - super().__init__(timeout=60) - self.user_id = user_id - self._res = res - self._fish_id = fish_id - self._weight = weight - self._done = False - - def _catch_embed(self, color: int = 0x57F287) -> discord.Embed: - rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"] - emoji = S.FISH_RARITY_EMOJI[rarity] - fish_name = S.FISH_NAMES[self._fish_id] - desc = S.FISH_UI["catch_desc"].format( - name=fish_name, weight=self._weight, - exp=self._res["exp"], value=_coin(self._res["value"]), - ) - if self._res.get("is_new"): - desc += S.FISH_UI["new_fish"] - return discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=color) - - @discord.ui.button(label="", style=discord.ButtonStyle.success) - async def sell_btn(self, interaction: discord.Interaction, button: 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._done: - await interaction.response.defer() - return - self._done = True - self.stop() - for child in self.children: - child.disabled = True - sell_res = await economy.do_fish_sell(self.user_id, [-1]) - rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"] - emoji = S.FISH_RARITY_EMOJI[rarity] - fish_name = S.FISH_NAMES[self._fish_id] - desc = S.FISH_UI["catch_sold"].format( - name=fish_name, weight=self._weight, - coins=_coin(sell_res["coins"]), exp=self._res["exp"], - balance=_coin(sell_res["balance"]), - ) - if self._res.get("is_new"): - desc += S.FISH_UI["new_fish"] - embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x57F287) - await interaction.response.edit_message(embed=embed, view=self) - - @discord.ui.button(label="", style=discord.ButtonStyle.secondary) - async def keep_btn(self, interaction: discord.Interaction, button: 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._done: - await interaction.response.defer() - return - self._done = True - self.stop() - for child in self.children: - child.disabled = True - rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"] - emoji = S.FISH_RARITY_EMOJI[rarity] - fish_name = S.FISH_NAMES[self._fish_id] - desc = S.FISH_UI["catch_kept"].format(name=fish_name, weight=self._weight, exp=self._res["exp"]) - if self._res.get("is_new"): - desc += S.FISH_UI["new_fish"] - embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x5865F2) - await interaction.response.edit_message(embed=embed, view=self) - - async def on_timeout(self): - for child in self.children: - child.disabled = True - - -class FishingView(discord.ui.View): - BITE_WINDOW = 2.0 - - def __init__(self, user_id: int, fish_id: str, weight: int): - super().__init__(timeout=40) - self.user_id = user_id - self._fish_id = fish_id - self._weight = weight - self._clicked = False - self._bite_active = False - self._msg: discord.Message | None = None - - self.pull_btn = discord.ui.Button( - label=S.FISH_UI["btn_wait"], - style=discord.ButtonStyle.secondary, - disabled=True, - ) - self.pull_btn.callback = self._pull - self.add_item(self.pull_btn) - - async def start(self, msg: discord.Message) -> None: - self._msg = msg - wait = random.uniform(5, 15) - await asyncio.sleep(wait) - if self._clicked or self.is_finished(): - return - self._bite_active = True - self.pull_btn.disabled = False - self.pull_btn.label = S.FISH_UI["btn_bite"] - self.pull_btn.style = discord.ButtonStyle.success - try: - await msg.edit( - embed=discord.Embed(title=S.TITLE["fish_bite"], description=S.FISH_UI["bite_desc"], color=0xED4245), - view=self, - ) - except Exception: - pass - await asyncio.sleep(self.BITE_WINDOW) - if not self._clicked: - self.stop() - _active_games.discard(self.user_id) - self.pull_btn.disabled = True - try: - await msg.edit( - embed=discord.Embed(title=S.TITLE["fish_escape"], description=S.FISH_UI["escape_desc"], color=0x99AAB5), - view=self, - ) - except Exception: - pass - - async def _pull(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 - if not self._bite_active: - await interaction.response.send_message(S.FISH_UI["too_early"], ephemeral=True) - return - self._clicked = True - self.stop() - _active_games.discard(self.user_id) - self.pull_btn.disabled = True - await interaction.response.defer() - - if self._fish_id == "junk": - junk_text = random.choice(S.FISH_JUNK_LINES) - user_data = await economy.get_user(interaction.user.id) - embed = discord.Embed( - title=S.TITLE["fish_junk"], - description=S.FISH_UI["junk_desc"].format(text=junk_text, balance=_coin(user_data.get("balance", 0))), - color=0x99AAB5, - ) - await self._msg.edit(embed=embed, view=self) - return - - res = await economy.do_fish_resolve(self.user_id, self._fish_id, self._weight) - if not res["ok"]: - await self._msg.edit(embed=discord.Embed(title=S.ERR["generic_error"], color=0xED4245), view=self) - return - - catch_view = FishCatchView(self.user_id, res, self._fish_id, self._weight) - catch_view.sell_btn.label = S.FISH_UI["btn_sell"] - catch_view.keep_btn.label = S.FISH_UI["btn_keep"] - await self._msg.edit(embed=catch_view._catch_embed(), view=catch_view) - if res.get("exp", 0) > 0: - asyncio.create_task(_award_exp(interaction, res["exp"])) - - async def on_timeout(self): - for child in self.children: - child.disabled = True - _active_games.discard(self.user_id) - - -@tree.command(name="fish", description=S.CMD["fish"]) -@app_commands.guild_only() -async def cmd_fish(interaction: discord.Interaction): - if await _check_cmd_rate(interaction): - return - if interaction.user.id in _active_games: - await interaction.response.send_message(S.ERR["game_in_progress"], ephemeral=True) - return - - res = await economy.do_fish_start(interaction.user.id) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "cooldown": - await interaction.response.send_message( - S.CD_MSG["fish"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - elif res["reason"] == "jailed": - await interaction.response.send_message( - S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True - ) - return - - user_data = await economy.get_user(interaction.user.id) - rarity_bump = "kalavork" in user_data.get("items", []) - has_echolood = "echolood" in user_data.get("items", []) - fish_id, weight = economy.roll_fish(rarity_bump=rarity_bump) - - _active_games.add(interaction.user.id) - view = FishingView(interaction.user.id, fish_id, weight) - if has_echolood: - view.BITE_WINDOW = 3.0 - embed = discord.Embed(title=S.TITLE["fish_cast"], description=S.FISH_UI["cast_desc"], color=0x5865F2) - await interaction.response.send_message(embed=embed, view=view) - msg = await interaction.original_response() - asyncio.create_task(view.start(msg)) - asyncio.create_task(_maybe_remind(interaction.user.id, "fish")) - - -@tree.command(name="fishbook", description=S.CMD["fishbook"]) -@app_commands.describe(kasutaja=S.OPT["fishbook_kasutaja"]) -async def cmd_fishbook(interaction: discord.Interaction, kasutaja: discord.Member | None = None): - target = kasutaja or interaction.user - res = await economy.do_fishbook(target.id) - book: dict = res["book"] - total = res["total_species"] - caught_count = res["unique_caught"] - - if not book: - embed = discord.Embed( - title=S.TITLE["fishbook"], - description=S.FISH_UI["book_empty"], - color=0x5865F2, - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - return - - inv_counts: dict = res.get("inv_counts", {}) - all_fish = list(economy.FISH_CATALOGUE.items()) - lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)] - for fish_id, fish_data in all_fish: - rarity = fish_data["rarity"] - emoji = S.FISH_RARITY_EMOJI[rarity] - rarity_name = S.FISH_RARITY_NAMES[rarity] - count = book.get(fish_id, 0) - if count > 0: - n_inv = inv_counts.get(fish_id, 0) - inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else "" - lines.append(S.FISH_UI["book_yes"].format(emoji=emoji, name=S.FISH_NAMES[fish_id], rarity=rarity_name, count=count, inv=inv_str)) - else: - lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name)) - - embed = discord.Embed( - title=S.TITLE["fishbook"].replace("Kalakogu", f"{target.display_name} kalakogu"), - description="\n".join(lines), - color=0x5865F2, - ) - embed.set_footer(text=S.FISH_UI["book_footer"].format(page=1, total_pages=1, caught=caught_count, total=total)) - await interaction.response.send_message(embed=embed, ephemeral=True) - - -# --------------------------------------------------------------------------- -# /fishsell -# --------------------------------------------------------------------------- -@tree.command(name="fishsell", description=S.CMD["fishsell"]) -@app_commands.guild_only() -async def cmd_fishsell(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - user_data = await economy.get_user(interaction.user.id) - inv: list = user_data.get("fish_inventory") or [] - if not inv: - embed = discord.Embed( - title=S.TITLE["fishbook"], - description=S.FISH_UI["inv_empty"], - color=0x5865F2, - ) - await interaction.followup.send(embed=embed, ephemeral=True) - return - - total_value = sum(e["value"] for e in inv) - lines = [S.FISH_UI["inv_header"].format(count=len(inv), total_value=_coin(total_value))] - for entry in inv: - fid = entry.get("fish_id", "") - rarity = economy.FISH_CATALOGUE.get(fid, {}).get("rarity", "common") - emoji = S.FISH_RARITY_EMOJI.get(rarity, "🐟") - name = S.FISH_NAMES.get(fid, fid) - lines.append(S.FISH_UI["inv_entry"].format(emoji=emoji, name=name, weight=entry["weight"], value=_coin(entry["value"]))) - - embed = discord.Embed(title=S.TITLE["fishbook"], description="\n".join(lines), color=0x5865F2) - - sell_all_btn = discord.ui.Button(label=S.FISH_UI["btn_sell"] + f" ({_coin(total_value)})", style=discord.ButtonStyle.success) - - async def _sell_all(btn_interaction: discord.Interaction): - if btn_interaction.user.id != interaction.user.id: - await btn_interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) - return - sell_view.stop() - for child in sell_view.children: - child.disabled = True - res = await economy.do_fish_sell(interaction.user.id) - if not res["ok"]: - await btn_interaction.response.edit_message( - embed=discord.Embed(description=S.FISH_UI["inv_none"], color=0x99AAB5), view=sell_view - ) - return - sold_embed = discord.Embed( - title=S.TITLE["fishbook"], - description=S.FISH_UI["inv_sold_all"].format( - count=res["count"], coins=_coin(res["coins"]), balance=_coin(res["balance"]) - ), - color=0x57F287, - ) - await btn_interaction.response.edit_message(embed=sold_embed, view=sell_view) - - sell_all_btn.callback = _sell_all - sell_view = discord.ui.View(timeout=60) - sell_view.add_item(sell_all_btn) - await interaction.followup.send(embed=embed, view=sell_view, ephemeral=True) +register_economy_extra_commands( + tree, + bot, + coin=_coin, + cd_ts=_cd_ts, + parse_amount=_parse_amount, + ensure_level_role=_ensure_level_role, + active_games=_active_games, +) # --------------------------------------------------------------------------- diff --git a/economy_extra_commands.py b/economy_extra_commands.py new file mode 100644 index 0000000..f031e88 --- /dev/null +++ b/economy_extra_commands.py @@ -0,0 +1,883 @@ +from __future__ import annotations + +import asyncio +import datetime +import random +import time +from collections.abc import Awaitable, Callable, MutableSet + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_economy_extra_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + coin: Callable[[int], str], + cd_ts: Callable[[datetime.timedelta], str], + parse_amount: Callable[[str, int], tuple[int | None, str | None]], + ensure_level_role: Callable[[discord.Member, int], Awaitable[None]], + active_games: MutableSet[int], +) -> None: + active_heist = None + + # ----------------------------------------------------------------------- + # /heist - multiplayer group robbery of the house + # ----------------------------------------------------------------------- + _HEIST_JOIN_WINDOW = 300 # seconds players have to join + _HEIST_MIN_PLAYERS = 2 + _HEIST_GLOBAL_CD = 14400 # seconds between heist events server-wide (4h) + _HEIST_MAX_PLAYERS = 8 + _HEIST_BASE_CHANCE = 0.35 # 35% solo + _HEIST_CHANCE_STEP = 0.05 # +5% per extra player + _HEIST_MAX_CHANCE = 0.65 # cap at 65% + + def _build_heist_story(participants: list[discord.Member], success: bool) -> list[str]: + """Return a list of story lines for the heist narrative reveal.""" + story = S.HEIST_STORY + leader = participants[0].display_name + if len(participants) == 1: + names = f"**{leader}**" + elif len(participants) == 2: + names = S.HEIST_UI["names_duo"].format( + a=participants[0].display_name, + b=participants[1].display_name, + ) + elif len(participants) <= 4: + names = S.HEIST_UI["names_sep"].join(f"**{p.display_name}**" for p in participants) + else: + names = S.HEIST_UI["names_crew"].format(leader=participants[0].display_name) + + vehicle = random.choice(story["vehicles"]) + approach = random.choice(["sneaky", "loud"]) + non_leaders = participants[1:] if len(participants) > 1 else participants + + def fill(tmpl: str) -> str: + picked = random.choice(non_leaders).display_name + return tmpl.format( + leader=f"**{leader}**", + member=f"**{picked}**", + names=names, + vehicle=vehicle, + ) + + getaway_pool = "getaway_success" if success else "getaway_fail" + + return [ + fill(random.choice(story["arrival"])), + fill(random.choice(story[f"entry_{approach}"])), + fill(random.choice(story["inside"])), + fill(random.choice(story["vault"])), + fill(random.choice(story["vault_open"])), + fill(random.choice(story["police_inbound"])), + fill(random.choice(story[getaway_pool])), + fill(random.choice(story["escape_success" if success else "escape_fail"])), + ] + + class HeistLobbyView(discord.ui.View): + def __init__(self, organizer: discord.Member, organizer_has_jellyfin: bool = False): + super().__init__(timeout=_HEIST_JOIN_WINDOW) + self.organizer = organizer + self.participants: list[discord.Member] = [organizer] + self.message: discord.Message | None = None + self.resolved = False + self.jellyfin_holders: int = 1 if organizer_has_jellyfin else 0 + + def _chance(self) -> float: + n = len(self.participants) + base = min(_HEIST_BASE_CHANCE + _HEIST_CHANCE_STEP * (n - 1), _HEIST_MAX_CHANCE) + jelly_bonus = 0.05 if self.jellyfin_holders > 0 else 0.0 + return min(base + jelly_bonus, _HEIST_MAX_CHANCE) + + def _lobby_embed(self) -> discord.Embed: + names = "\n".join(f"• {p.display_name}" for p in self.participants) + desc = S.HEIST_UI["lobby_desc"].format( + n=len(self.participants), + max=_HEIST_MAX_PLAYERS, + names=names, + chance=int(self._chance() * 100), + ts=int(self._timeout_expiry()), + ) + return discord.Embed(title=S.TITLE["heist_lobby"], description=desc, color=0xE67E22) + + def _timeout_expiry(self) -> float: + return time.time() + (self.timeout or 0) + + @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": + 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.CD_MSG["heist"].format(ts=cd_ts(res["remaining"])), ephemeral=True + ) + return + self.participants.append(interaction.user) + active_games.add(interaction.user.id) + joiner_data = await economy.get_user(interaction.user.id) + if "jellyfin" in joiner_data.get("items", []): + self.jellyfin_holders += 1 + await interaction.response.edit_message(embed=self._lobby_embed()) + + @discord.ui.button(label=S.HEIST_UI["btn_start"], style=discord.ButtonStyle.success) + async def start_now(self, interaction: discord.Interaction, _: discord.ui.Button): + if interaction.user.id != self.organizer.id: + await interaction.response.send_message(S.HEIST_UI["only_organizer"], ephemeral=True) + return + if len(self.participants) < _HEIST_MIN_PLAYERS: + await interaction.response.send_message( + S.ERR["heist_min_players"].format(min=_HEIST_MIN_PLAYERS), ephemeral=True + ) + return + await self._resolve(interaction) + + async def _resolve(self, interaction: discord.Interaction | None = None) -> None: + nonlocal active_heist + if self.resolved: + return + self.resolved = True + active_heist = None + self.stop() + self.clear_items() + + for p in self.participants: + active_games.discard(p.id) + + n = len(self.participants) + channel = interaction.channel if interaction else self.message.channel if self.message else None + + if n < _HEIST_MIN_PLAYERS: + embed = discord.Embed( + title=S.TITLE["heist_cancel"], + description=S.HEIST_UI["cancel_desc"].format(min=_HEIST_MIN_PLAYERS), + color=0x99AAB5, + ) + if interaction and not interaction.response.is_done(): + await interaction.response.edit_message(embed=embed, view=self) + elif self.message: + try: + await self.message.edit(embed=embed, view=self) + except discord.HTTPException: + pass + return + + success = random.random() < self._chance() + story_lines = _build_heist_story(self.participants, success) + + lobby_done = discord.Embed( + title=S.HEIST_UI["started_title"], + description=S.HEIST_UI["started_desc"].format(n=n), + color=0x99AAB5, + ) + if interaction and not interaction.response.is_done(): + await interaction.response.edit_message(embed=lobby_done, view=self) + elif self.message: + try: + await self.message.edit(embed=lobby_done, view=self) + except discord.HTTPException: + pass + + if channel: + story_embed = discord.Embed(title=S.HEIST_UI["story_title"], description="", color=0xE67E22) + story_msg = await channel.send(embed=story_embed) + accumulated = "" + for i, line in enumerate(story_lines): + await asyncio.sleep(random.uniform(3.0, 4.5)) + accumulated += ("\n\n" if i > 0 else "") + line + story_embed.description = accumulated + try: + await story_msg.edit(embed=story_embed) + except discord.HTTPException: + pass + await asyncio.sleep(2.0) + + res = await economy.do_heist_resolve([p.id for p in self.participants], success) + payout_each = res["payout_each"] + names_str = "\n".join(f"• {p.display_name}" for p in self.participants) + guild = interaction.guild if interaction else self.message.guild if self.message else None + + if success: + result_desc = S.HEIST_UI["win_desc"].format(names=names_str, payout=coin(payout_each)) + result_embed = discord.Embed( + title=S.TITLE["heist_win"], + description=result_desc, + color=0x57F287, + ) + for p in self.participants: + exp_res = await economy.award_exp(p.id, economy.EXP_REWARDS["heist_win"]) + if exp_res["old_level"] != exp_res["new_level"] and guild: + gm = guild.get_member(p.id) + if gm: + asyncio.create_task(ensure_level_role(gm, exp_res["new_level"])) + else: + result_desc = S.HEIST_UI["fail_desc"].format(names=names_str) + result_embed = discord.Embed( + title=S.TITLE["heist_fail"], + description=result_desc, + color=0xED4245, + ) + + await economy.set_heist_global_cd(time.time() + _HEIST_GLOBAL_CD) + + if channel: + await channel.send(embed=result_embed) + elif self.message: + try: + await self.message.channel.send(embed=result_embed) + except discord.HTTPException: + pass + + async def on_timeout(self) -> None: + await self._resolve() + + @tree.command(name="heist", description=S.CMD["heist"]) + @app_commands.guild_only() + async def cmd_heist(interaction: discord.Interaction): + nonlocal active_heist + 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 + + organizer_data = await economy.get_user(interaction.user.id) + view = HeistLobbyView(interaction.user, "jellyfin" in organizer_data.get("items", [])) + 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() + + # ----------------------------------------------------------------------- + # /jailbreak - Monopoly-style dice escape + # ----------------------------------------------------------------------- + _DICE_EMOJI = [ + "<:TipiYKS:1483103190491856916>", + "<:TipiKAKS:1483103215841972404>", + "<:TipiKOLM:1483103217846980781>", + "<:TipiNELI:1483103237585240114>", + "<:TipiVIIS:1483103239036469289>", + "<:TipiKUUS:1483103253163020348>", + ] + + class JailbreakView(discord.ui.View): + MAX_TRIES = 3 + + def __init__(self, user_id: int): + super().__init__(timeout=120) + self.user_id = user_id + self.tries = 0 + self._rolling = False + self._add_roll_btn() + + def _add_roll_btn(self): + self.clear_items() + btn = discord.ui.Button( + label=S.JAILBREAK_UI["btn_roll"].format(try_=self.tries + 1, max=self.MAX_TRIES), + style=discord.ButtonStyle.primary, + ) + btn.callback = self._on_roll + self.add_item(btn) + + async def _on_roll(self, interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + if self._rolling: + await interaction.response.defer() + return + self._rolling = True + + self.clear_items() + rolling_embed = discord.Embed( + title=S.TITLE["jailbreak"], + description=S.JAILBREAK_UI["rolling_desc"], + color=0xF4C430, + ) + await interaction.response.edit_message(embed=rolling_embed, view=self) + + d1 = random.randint(1, 6) + d2 = random.randint(1, 6) + e1, e2 = _DICE_EMOJI[d1 - 1], _DICE_EMOJI[d2 - 1] + double = d1 == d2 + self.tries += 1 + tries_left = self.MAX_TRIES - self.tries + + await asyncio.sleep(1.5) + self._rolling = False + + if double: + await economy.do_jail_free(self.user_id) + self.stop() + embed = discord.Embed( + title=S.TITLE["jailbreak_free"], + description=S.JAILBREAK_UI["free_desc"].format(d1=e1, d2=e2), + color=0x57F287, + ) + await interaction.edit_original_response(embed=embed, view=self) + elif tries_left == 0: + self.stop() + user_data = await economy.get_user(self.user_id) + bal = user_data["balance"] + if bal >= economy.MIN_BAIL: + min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) + max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) + desc = S.JAILBREAK_UI["fail_bail_offer"].format( + d1=e1, + d2=e2, + min=coin(min_fine), + max=coin(max_fine), + bal=coin(bal), + ) + embed = discord.Embed( + title=S.TITLE["jailbreak_fail"], + description=desc, + color=0xED4245, + ) + await interaction.edit_original_response(embed=embed, view=BailView(self.user_id)) + else: + embed = discord.Embed( + title=S.TITLE["jailbreak_fail"], + description=S.JAILBREAK_UI["fail_broke_desc"].format( + d1=e1, + d2=e2, + balance=coin(bal), + ), + color=0xED4245, + ) + await interaction.edit_original_response(embed=embed, view=None) + else: + self._add_roll_btn() + embed = discord.Embed( + title=S.TITLE["jailbreak_miss"].format(tries=self.tries, max=self.MAX_TRIES), + description=S.JAILBREAK_UI["miss_desc"].format(d1=e1, d2=e2, left=tries_left), + color=0xF4C430, + ) + await interaction.edit_original_response(embed=embed, view=self) + + class BailView(discord.ui.View): + def __init__(self, user_id: int): + super().__init__(timeout=60) + self.user_id = user_id + + @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 + res = await economy.do_bail(self.user_id) + self.clear_items() + self.stop() + if not res["ok"] and res.get("reason") == "broke": + embed = discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=S.JAILBREAK_UI["bail_broke_desc"].format( + min=coin(economy.MIN_BAIL), + balance=coin(res["balance"]), + ), + color=0xED4245, + ) + else: + embed = discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=S.JAILBREAK_UI["bail_paid_desc"].format( + fine=coin(res["fine"]), + balance=coin(res["balance"]), + ), + color=0x57F287, + ) + await interaction.response.edit_message(embed=embed, view=self) + + @tree.command(name="jailbreak", description=S.CMD["jailbreak"]) + async def cmd_jailbreak(interaction: discord.Interaction): + user_data = await economy.get_user(interaction.user.id) + remaining = economy._is_jailed(user_data) + if not remaining: + await interaction.response.send_message(S.ERR["not_jailed"], ephemeral=True) + return + + if user_data.get("jailbreak_used", False): + bal = user_data["balance"] + min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) + max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) + if bal >= economy.MIN_BAIL: + desc = S.JAILBREAK_UI["already_bail"].format( + min=coin(min_fine), + max=coin(max_fine), + bal=coin(bal), + ts=cd_ts(remaining), + ) + await interaction.response.send_message( + embed=discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=desc, + color=0xED4245, + ), + view=BailView(interaction.user.id), + ephemeral=True, + ) + else: + desc = S.JAILBREAK_UI["already_broke"].format( + min=coin(economy.MIN_BAIL), + bal=coin(bal), + ts=cd_ts(remaining), + ) + await interaction.response.send_message( + embed=discord.Embed( + title=S.TITLE["jailbreak_bail"], + description=desc, + color=0xED4245, + ), + ephemeral=True, + ) + return + + await economy.set_jailbreak_used(interaction.user.id) + embed = discord.Embed( + title=S.TITLE["jailbreak"], + description=S.JAILBREAK_UI["intro_desc"].format( + ts=cd_ts(remaining), + tries=JailbreakView.MAX_TRIES, + ), + color=0xF4C430, + ) + await interaction.response.send_message( + embed=embed, + view=JailbreakView(interaction.user.id), + ephemeral=True, + ) + + @tree.command(name="give", description=S.CMD["give"]) + @app_commands.describe(kasutaja=S.OPT["give_kasutaja"], summa=S.OPT["give_summa"]) + async def cmd_give(interaction: discord.Interaction, kasutaja: discord.Member, summa: str): + data = await economy.get_user(interaction.user.id) + summa_int, err = parse_amount(summa, data["balance"]) + if err: + await interaction.response.send_message(err, ephemeral=True) + return + if summa_int is None or summa_int <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + if kasutaja.id == interaction.user.id: + await interaction.response.send_message(S.ERR["give_self"], ephemeral=True) + return + if kasutaja.bot: + await interaction.response.send_message(S.ERR["give_bot"], ephemeral=True) + return + + res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int) + 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.ERR["give_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 + + embed = discord.Embed( + title=f"{economy.COIN} {S.TITLE['give']}", + description=S.GIVE_UI["desc"].format( + giver=interaction.user.display_name, + amount=coin(summa_int), + receiver=kasutaja.display_name, + ), + color=0xF4C430, + ) + await interaction.response.send_message(embed=embed) + + class LeaderboardView(discord.ui.View): + PER_PAGE = 10 + + def __init__(self, data: dict, guild: discord.Guild | None, bot_user: discord.ClientUser | None): + super().__init__(timeout=120) + self.data = data + self.guild = guild + self.bot_user = bot_user + self.page = 0 + self.mode = "coins" + self.max_page = 0 + self._update_buttons() + + def _current_list(self) -> list: + return self.data.get(self.mode, []) + + def _update_buttons(self): + current = self._current_list() + self.max_page = max(0, (len(current) - 1) // self.PER_PAGE) if current else 0 + self.prev_btn.disabled = self.page == 0 + self.next_btn.disabled = self.page >= self.max_page + for m, btn in [ + ("coins", self.coins_btn), + ("exp", self.exp_btn), + ("season", self.season_btn), + ("prestige", self.prestige_btn), + ("wagered", self.wagered_btn), + ("fish", self.fish_btn), + ]: + btn.style = discord.ButtonStyle.primary if m == self.mode else discord.ButtonStyle.secondary + + def _name(self, uid: str, highlight_uid: int | None = None) -> str: + if self.guild: + member = self.guild.get_member(int(uid)) + name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) + else: + name = S.LEADERBOARD_UI["unknown_user"].format(uid=uid) + if highlight_uid and int(uid) == highlight_uid: + name = f"**› {name} ‹**" + return name + + def _make_embed(self, highlight_uid: int | None = None) -> discord.Embed: + title_map = { + "coins": f"{economy.COIN} {S.TITLE['leaderboard_coins']}", + "exp": S.TITLE["leaderboard_exp"], + "season": S.TITLE["leaderboard_season"], + "prestige": S.TITLE["leaderboard_prestige"], + "wagered": S.TITLE["leaderboard_wagered"], + "fish": S.TITLE["leaderboard_fish"], + } + color_map = {"coins": 0xF4C430, "wagered": 0xED4245, "fish": 0x57F287} + embed = discord.Embed( + title=title_map.get(self.mode, "Edetabel"), + color=color_map.get(self.mode, 0x5865F2), + ) + lines = [] + + if self.mode == "coins" and self.page == 0 and self.data.get("house_entry"): + _, bal = self.data["house_entry"] + house_name = self.bot_user.display_name if self.bot_user else S.LEADERBOARD_UI["house_default_name"] + lines.append(S.LEADERBOARD_UI["house_entry"].format(name=house_name, balance=coin(bal))) + lines.append("") + + start = self.page * self.PER_PAGE + medals = ["🥇", "🥈", "🥉"] + current = self._current_list() + slice_ = current[start : start + self.PER_PAGE] + + if not slice_: + lines.append(S.LEADERBOARD_UI["no_entries"]) + else: + for i, entry in enumerate(slice_): + rank = start + i + uid = entry[0] + prefix = medals[rank] if rank < 3 else f"**{rank + 1}.**" + name = self._name(uid, highlight_uid) + if self.mode == "coins": + lines.append(f"{prefix} {name} - {coin(entry[1])}") + elif self.mode == "exp": + lines.append( + S.LEADERBOARD_UI["exp_entry"].format( + prefix=prefix, + name=name, + exp=entry[1], + level=entry[2], + ) + ) + elif self.mode == "season": + lines.append( + S.LEADERBOARD_UI["season_entry"].format( + prefix=prefix, + name=name, + exp=entry[1], + prestige=entry[2], + ) + ) + elif self.mode == "prestige": + lines.append( + S.LEADERBOARD_UI["prestige_entry"].format( + prefix=prefix, + name=name, + prestige=entry[1], + pp=entry[2], + ) + ) + elif self.mode == "wagered": + lines.append( + S.LEADERBOARD_UI["wagered_entry"].format( + prefix=prefix, + name=name, + wagered=coin(entry[1]), + ) + ) + elif self.mode == "fish": + lines.append( + S.LEADERBOARD_UI["fish_entry"].format( + prefix=prefix, + name=name, + caught=entry[1], + ) + ) + + total = self.max_page + 1 + embed.description = "\n".join(lines) + embed.set_footer( + text=S.LEADERBOARD_UI["footer"].format( + page=self.page + 1, + total=total, + count=len(current), + ) + ) + return embed + + @discord.ui.button(label="◄", style=discord.ButtonStyle.secondary, row=0) + async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page -= 1 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label="►", style=discord.ButtonStyle.secondary, row=0) + async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page += 1 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_find_me"], style=discord.ButtonStyle.secondary, row=0) + async def find_me_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + uid = interaction.user.id + for i, entry in enumerate(self._current_list()): + if int(entry[0]) == uid: + self.page = i // self.PER_PAGE + self._update_buttons() + await interaction.response.edit_message( + embed=self._make_embed(highlight_uid=uid), + view=self, + ) + return + await interaction.response.send_message(S.ERR["not_in_leaderboard"], ephemeral=True) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_coins"], style=discord.ButtonStyle.primary, row=1) + async def coins_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "coins" + self.page = 0 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_exp"], style=discord.ButtonStyle.secondary, row=1) + async def exp_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "exp" + self.page = 0 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_season"], style=discord.ButtonStyle.secondary, row=1) + async def season_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "season" + self.page = 0 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_prestige"], style=discord.ButtonStyle.secondary, row=1) + async def prestige_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "prestige" + self.page = 0 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_wagered"], style=discord.ButtonStyle.secondary, row=1) + async def wagered_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "wagered" + self.page = 0 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + @discord.ui.button(label=S.LEADERBOARD_UI["btn_fish"], style=discord.ButtonStyle.secondary, row=2) + async def fish_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.mode = "fish" + self.page = 0 + self._update_buttons() + await interaction.response.edit_message(embed=self._make_embed(), view=self) + + async def on_timeout(self): + for child in self.children: + child.disabled = True + + @tree.command(name="leaderboard", description=S.CMD["leaderboard"]) + async def cmd_leaderboard(interaction: discord.Interaction): + await interaction.response.defer() + coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather( + economy.get_leaderboard(top_n=None), + economy.get_leaderboard_exp(top_n=None), + economy.get_leaderboard_season_exp(top_n=None), + economy.get_leaderboard_prestige(top_n=None), + economy.get_leaderboard_wagered(top_n=None), + economy.get_leaderboard_fish(top_n=None), + ) + + house_entry = None + regular = [] + bot_id = bot.user.id if bot.user else None + for uid, bal in coins_raw: + if bot_id and int(uid) == bot_id: + house_entry = (uid, bal) + else: + regular.append((uid, bal)) + + def _no_bot(entries: list) -> list: + return [e for e in entries if not (bot_id and int(e[0]) == bot_id)] + + data = { + "coins": regular, + "exp": _no_bot(exp_raw), + "season": _no_bot(season_raw), + "prestige": _no_bot(prestige_raw), + "wagered": _no_bot(wagered_raw), + "fish": _no_bot(fish_raw), + "house_entry": house_entry, + } + view = LeaderboardView(data, interaction.guild, bot.user) + await interaction.followup.send(embed=view._make_embed(), view=view) + + def _shop_embed(tier: int, user_data: dict) -> discord.Embed: + owned = set(user_data.get("items", [])) + item_uses = user_data.get("item_uses", {}) + tier_names = {1: S.SHOP_UI["tier_1"], 2: S.SHOP_UI["tier_2"], 3: S.SHOP_UI["tier_3"]} + embed = discord.Embed( + title=f"{economy.COIN} TipiBOTi pood · {tier_names[tier]}", + description=S.SHOP_UI["desc"].format(bal=coin(user_data["balance"])), + color=[0x57F287, 0xF4C430, 0xED4245][tier - 1], + ) + for item_id in sorted(economy.SHOP_TIERS[tier], key=lambda k: economy.SHOP[k]["cost"]): + item = economy.SHOP[item_id] + anticheat_uses = item_uses.get("anticheat", 0) if item_id == "anticheat" else 0 + min_lvl = economy.SHOP_LEVEL_REQ.get(item_id, 0) + user_lvl = economy.get_level(user_data.get("exp", 0)) + if item_id in owned and not (item_id == "anticheat" and anticheat_uses <= 0): + if item_id == "anticheat": + key = "owned_uses_1" if anticheat_uses == 1 else "owned_uses_n" + status = S.SHOP_UI[key].format(uses=anticheat_uses) + else: + status = S.SHOP_UI["owned"] + elif min_lvl > 0 and user_lvl < min_lvl: + status = S.SHOP_UI["locked"].format(min_lvl=min_lvl, user_lvl=user_lvl) + else: + status = f"{item['cost']} {economy.COIN}" + embed.add_field( + name=f"{item['emoji']} {item['name']} · {status}", + value=item["description"], + inline=False, + ) + return embed + + class ShopView(discord.ui.View): + def __init__(self, user_data: dict, tier: int = 1): + super().__init__(timeout=120) + self._user_data = user_data + self._tier = tier + self._update_buttons() + + def _update_buttons(self): + self.clear_items() + for t, label in [(1, S.SHOP_BTN[1]), (2, S.SHOP_BTN[2]), (3, S.SHOP_BTN[3])]: + btn = discord.ui.Button( + label=label, + style=discord.ButtonStyle.primary if t == self._tier else discord.ButtonStyle.secondary, + custom_id=f"shop_tier_{t}", + ) + btn.callback = self._make_callback(t) + self.add_item(btn) + + def _make_callback(self, tier: int): + async def callback(interaction: discord.Interaction): + self._tier = tier + self._update_buttons() + self._user_data = await economy.get_user(interaction.user.id) + await interaction.response.edit_message( + embed=_shop_embed(self._tier, self._user_data), + view=self, + ) + + return callback + + @tree.command(name="shop", description=S.CMD["shop"]) + async def cmd_shop(interaction: discord.Interaction): + data = await economy.get_user(interaction.user.id) + await interaction.response.send_message( + embed=_shop_embed(1, data), + view=ShopView(data, tier=1), + ephemeral=True, + ) + + @tree.command(name="buy", description=S.CMD["buy"]) + @app_commands.describe(ese=S.OPT["buy_ese"]) + @app_commands.choices( + ese=[ + app_commands.Choice(name=f"{v['name']} ({v['cost']} TipiCOINi)", value=k) + for k, v in economy.SHOP.items() + ] + ) + async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]): + res = await economy.do_buy(interaction.user.id, ese.value) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "owned": + await interaction.response.send_message(S.ERR["item_owned"], ephemeral=True) + elif res["reason"] == "level_required": + await interaction.response.send_message( + S.ERR["item_level_req"].format( + min_level=res["min_level"], + user_level=res["user_level"], + ), + ephemeral=True, + ) + elif res["reason"] == "insufficient": + await interaction.response.send_message( + S.ERR["broke_need"].format(need=coin(res["need"])), + ephemeral=True, + ) + else: + await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True) + return + + item = res["item"] + embed = discord.Embed( + title=S.BUY_UI["title"].format(emoji=item["emoji"], name=item["name"]), + description=S.BUY_UI["desc"].format( + description=item["description"], + balance=coin(res["balance"]), + ), + color=0x57F287, + ) + await interaction.response.send_message(embed=embed) diff --git a/economy_fish_commands.py b/economy_fish_commands.py new file mode 100644 index 0000000..9f96d4b --- /dev/null +++ b/economy_fish_commands.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import asyncio +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_fish_commands( + tree: app_commands.CommandTree, + coin: Callable[[int], str], + cd_ts: Callable, + check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]], + maybe_remind: Callable[[int, str], Awaitable[None]], + award_exp: Callable[[discord.Interaction, int], Awaitable[None]], + active_games: MutableSet[int], +) -> None: + class FishCatchView(discord.ui.View): + """Shown after a successful pull - lets user sell or keep the fish.""" + + def __init__(self, user_id: int, res: dict, fish_id: str, weight: int): + super().__init__(timeout=60) + self.user_id = user_id + self._res = res + self._fish_id = fish_id + self._weight = weight + self._done = False + + def _catch_embed(self, color: int = 0x57F287) -> discord.Embed: + rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"] + emoji = S.FISH_RARITY_EMOJI[rarity] + fish_name = S.FISH_NAMES[self._fish_id] + desc = S.FISH_UI["catch_desc"].format( + name=fish_name, + weight=self._weight, + exp=self._res["exp"], + value=coin(self._res["value"]), + ) + if self._res.get("is_new"): + desc += S.FISH_UI["new_fish"] + return discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=color) + + @discord.ui.button(label="", style=discord.ButtonStyle.success) + async def sell_btn(self, interaction: discord.Interaction, button: 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._done: + await interaction.response.defer() + return + self._done = True + self.stop() + for child in self.children: + child.disabled = True + sell_res = await economy.do_fish_sell(self.user_id, [-1]) + rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"] + emoji = S.FISH_RARITY_EMOJI[rarity] + fish_name = S.FISH_NAMES[self._fish_id] + desc = S.FISH_UI["catch_sold"].format( + name=fish_name, + weight=self._weight, + coins=coin(sell_res["coins"]), + exp=self._res["exp"], + balance=coin(sell_res["balance"]), + ) + if self._res.get("is_new"): + desc += S.FISH_UI["new_fish"] + embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x57F287) + await interaction.response.edit_message(embed=embed, view=self) + + @discord.ui.button(label="", style=discord.ButtonStyle.secondary) + async def keep_btn(self, interaction: discord.Interaction, button: 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._done: + await interaction.response.defer() + return + self._done = True + self.stop() + for child in self.children: + child.disabled = True + rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"] + emoji = S.FISH_RARITY_EMOJI[rarity] + fish_name = S.FISH_NAMES[self._fish_id] + desc = S.FISH_UI["catch_kept"].format( + name=fish_name, + weight=self._weight, + exp=self._res["exp"], + ) + if self._res.get("is_new"): + desc += S.FISH_UI["new_fish"] + embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x5865F2) + await interaction.response.edit_message(embed=embed, view=self) + + async def on_timeout(self): + for child in self.children: + child.disabled = True + + class FishingView(discord.ui.View): + BITE_WINDOW = 2.0 + + def __init__(self, user_id: int, fish_id: str, weight: int): + super().__init__(timeout=40) + self.user_id = user_id + self._fish_id = fish_id + self._weight = weight + self._clicked = False + self._bite_active = False + self._msg: discord.Message | None = None + + self.pull_btn = discord.ui.Button( + label=S.FISH_UI["btn_wait"], + style=discord.ButtonStyle.secondary, + disabled=True, + ) + self.pull_btn.callback = self._pull + self.add_item(self.pull_btn) + + async def start(self, msg: discord.Message) -> None: + self._msg = msg + wait = random.uniform(5, 15) + await asyncio.sleep(wait) + if self._clicked or self.is_finished(): + return + self._bite_active = True + self.pull_btn.disabled = False + self.pull_btn.label = S.FISH_UI["btn_bite"] + self.pull_btn.style = discord.ButtonStyle.success + try: + await msg.edit( + embed=discord.Embed( + title=S.TITLE["fish_bite"], + description=S.FISH_UI["bite_desc"], + color=0xED4245, + ), + view=self, + ) + except Exception: + pass + await asyncio.sleep(self.BITE_WINDOW) + if not self._clicked: + self.stop() + active_games.discard(self.user_id) + self.pull_btn.disabled = True + try: + await msg.edit( + embed=discord.Embed( + title=S.TITLE["fish_escape"], + description=S.FISH_UI["escape_desc"], + color=0x99AAB5, + ), + view=self, + ) + except Exception: + pass + + async def _pull(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 + if not self._bite_active: + await interaction.response.send_message(S.FISH_UI["too_early"], ephemeral=True) + return + self._clicked = True + self.stop() + active_games.discard(self.user_id) + self.pull_btn.disabled = True + await interaction.response.defer() + + if self._fish_id == "junk": + junk_text = random.choice(S.FISH_JUNK_LINES) + user_data = await economy.get_user(interaction.user.id) + embed = discord.Embed( + title=S.TITLE["fish_junk"], + description=S.FISH_UI["junk_desc"].format( + text=junk_text, + balance=coin(user_data.get("balance", 0)), + ), + color=0x99AAB5, + ) + await self._msg.edit(embed=embed, view=self) + return + + res = await economy.do_fish_resolve(self.user_id, self._fish_id, self._weight) + if not res["ok"]: + await self._msg.edit( + embed=discord.Embed(title=S.ERR["generic_error"], color=0xED4245), + view=self, + ) + return + + catch_view = FishCatchView(self.user_id, res, self._fish_id, self._weight) + catch_view.sell_btn.label = S.FISH_UI["btn_sell"] + catch_view.keep_btn.label = S.FISH_UI["btn_keep"] + await self._msg.edit(embed=catch_view._catch_embed(), view=catch_view) + if res.get("exp", 0) > 0: + asyncio.create_task(award_exp(interaction, res["exp"])) + + async def on_timeout(self): + for child in self.children: + child.disabled = True + active_games.discard(self.user_id) + + @tree.command(name="fish", description=S.CMD["fish"]) + @app_commands.guild_only() + async def cmd_fish(interaction: discord.Interaction): + if await check_cmd_rate(interaction): + return + if interaction.user.id in active_games: + await interaction.response.send_message(S.ERR["game_in_progress"], ephemeral=True) + return + + res = await economy.do_fish_start(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["fish"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + return + + user_data = await economy.get_user(interaction.user.id) + rarity_bump = "kalavork" in user_data.get("items", []) + has_echolood = "echolood" in user_data.get("items", []) + fish_id, weight = economy.roll_fish(rarity_bump=rarity_bump) + + active_games.add(interaction.user.id) + view = FishingView(interaction.user.id, fish_id, weight) + if has_echolood: + view.BITE_WINDOW = 3.0 + embed = discord.Embed( + title=S.TITLE["fish_cast"], + description=S.FISH_UI["cast_desc"], + color=0x5865F2, + ) + await interaction.response.send_message(embed=embed, view=view) + msg = await interaction.original_response() + asyncio.create_task(view.start(msg)) + asyncio.create_task(maybe_remind(interaction.user.id, "fish")) + + @tree.command(name="fishbook", description=S.CMD["fishbook"]) + @app_commands.describe(kasutaja=S.OPT["fishbook_kasutaja"]) + async def cmd_fishbook(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + res = await economy.do_fishbook(target.id) + book: dict = res["book"] + total = res["total_species"] + caught_count = res["unique_caught"] + + if not book: + embed = discord.Embed( + title=S.TITLE["fishbook"], + description=S.FISH_UI["book_empty"], + color=0x5865F2, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + inv_counts: dict = res.get("inv_counts", {}) + all_fish = list(economy.FISH_CATALOGUE.items()) + lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)] + for fish_id, fish_data in all_fish: + rarity = fish_data["rarity"] + emoji = S.FISH_RARITY_EMOJI[rarity] + rarity_name = S.FISH_RARITY_NAMES[rarity] + count = book.get(fish_id, 0) + if count > 0: + n_inv = inv_counts.get(fish_id, 0) + inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else "" + lines.append( + S.FISH_UI["book_yes"].format( + emoji=emoji, + name=S.FISH_NAMES[fish_id], + rarity=rarity_name, + count=count, + inv=inv_str, + ) + ) + else: + lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name)) + + embed = discord.Embed( + title=S.TITLE["fishbook"].replace("Kalakogu", f"{target.display_name} kalakogu"), + description="\n".join(lines), + color=0x5865F2, + ) + embed.set_footer( + text=S.FISH_UI["book_footer"].format( + page=1, + total_pages=1, + caught=caught_count, + total=total, + ) + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @tree.command(name="fishsell", description=S.CMD["fishsell"]) + @app_commands.guild_only() + async def cmd_fishsell(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + user_data = await economy.get_user(interaction.user.id) + inv: list = user_data.get("fish_inventory") or [] + if not inv: + embed = discord.Embed( + title=S.TITLE["fishbook"], + description=S.FISH_UI["inv_empty"], + color=0x5865F2, + ) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + total_value = sum(e["value"] for e in inv) + lines = [S.FISH_UI["inv_header"].format(count=len(inv), total_value=coin(total_value))] + for entry in inv: + fid = entry.get("fish_id", "") + rarity = economy.FISH_CATALOGUE.get(fid, {}).get("rarity", "common") + emoji = S.FISH_RARITY_EMOJI.get(rarity, "🐟") + name = S.FISH_NAMES.get(fid, fid) + lines.append( + S.FISH_UI["inv_entry"].format( + emoji=emoji, + name=name, + weight=entry["weight"], + value=coin(entry["value"]), + ) + ) + + embed = discord.Embed( + title=S.TITLE["fishbook"], + description="\n".join(lines), + color=0x5865F2, + ) + + sell_all_btn = discord.ui.Button( + label=S.FISH_UI["btn_sell"] + f" ({coin(total_value)})", + style=discord.ButtonStyle.success, + ) + + async def _sell_all(btn_interaction: discord.Interaction): + if btn_interaction.user.id != interaction.user.id: + await btn_interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + sell_view.stop() + for child in sell_view.children: + child.disabled = True + res = await economy.do_fish_sell(interaction.user.id) + if not res["ok"]: + await btn_interaction.response.edit_message( + embed=discord.Embed(description=S.FISH_UI["inv_none"], color=0x99AAB5), + view=sell_view, + ) + return + sold_embed = discord.Embed( + title=S.TITLE["fishbook"], + description=S.FISH_UI["inv_sold_all"].format( + count=res["count"], + coins=coin(res["coins"]), + balance=coin(res["balance"]), + ), + color=0x57F287, + ) + await btn_interaction.response.edit_message(embed=sold_embed, view=sell_view) + + sell_all_btn.callback = _sell_all + sell_view = discord.ui.View(timeout=60) + sell_view.add_item(sell_all_btn) + await interaction.followup.send(embed=embed, view=sell_view, ephemeral=True) diff --git a/economy_games_commands.py b/economy_games_commands.py new file mode 100644 index 0000000..e970bfa --- /dev/null +++ b/economy_games_commands.py @@ -0,0 +1,1132 @@ +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, + ) diff --git a/economy_income_commands.py b/economy_income_commands.py new file mode 100644 index 0000000..10613f8 --- /dev/null +++ b/economy_income_commands.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import asyncio +import datetime +from collections.abc import Awaitable, Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_economy_income_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + coin: Callable[[int], str], + cd_ts: Callable[[datetime.timedelta], str], + check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]], + maybe_remind: Callable[[int, str], Awaitable[None]], + award_exp: Callable[[discord.Interaction, int], Awaitable[None]], +) -> None: + @tree.command(name="daily", description=S.CMD["daily"]) + async def cmd_daily(interaction: discord.Interaction): + res = await economy.do_daily(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["daily"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + return + + streak = res["streak"] + streak_str = f"🔥 {streak}p" + ( + " (+200%)" + if res["streak_mult"] >= 3.0 + else " (+100%)" + if res["streak_mult"] >= 2.0 + else " (+50%)" + if res["streak_mult"] >= 1.5 + else "" + ) + lines = [S.DAILY_UI["earned"].format(earned=coin(res["earned"]))] + if res["interest"]: + lines.append(S.DAILY_UI["interest"].format(interest=coin(res["interest"]))) + if res["vip"]: + lines.append(S.DAILY_UI["vip"]) + lines.append(S.DAILY_UI["footer"].format(streak_str=streak_str, balance=coin(res["balance"]))) + + embed = discord.Embed(title=S.TITLE["daily"], description="\n".join(lines), color=0xF4C430) + await interaction.response.send_message(embed=embed) + asyncio.create_task(maybe_remind(interaction.user.id, "daily")) + asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["daily"])) + + @tree.command(name="work", description=S.CMD["work"]) + async def cmd_work(interaction: discord.Interaction): + if await check_cmd_rate(interaction): + return + res = await economy.do_work(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["work"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + return + + desc = S.WORK_UI["desc"].format(job=res["job"], earned=coin(res["earned"])) + if res["lucky"]: + desc += S.WORK_UI["redbull"] + if res["hiir"]: + desc += S.WORK_UI["hiir"] + if res["laud"]: + desc += S.WORK_UI["laud"] + desc += S.WORK_UI["balance"].format(balance=coin(res["balance"])) + embed = discord.Embed(title=S.TITLE["work"], description=desc, color=0x57F287) + await interaction.response.send_message(embed=embed) + asyncio.create_task(maybe_remind(interaction.user.id, "work")) + asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["work"])) + + @tree.command(name="beg", description=S.CMD["beg"]) + async def cmd_beg(interaction: discord.Interaction): + if await check_cmd_rate(interaction): + return + res = await economy.do_beg(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["beg"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + return + + if res["jailed"]: + title = "🔒 " + S.TITLE["beg"] + color = 0xE67E22 + else: + title = S.TITLE["beg"] + color = 0x99AAB5 + beg_lines = [S.BEG_UI["desc"].format(text=res["text"], earned=coin(res["earned"]))] + if res["klaviatuur"]: + beg_lines.append(S.BEG_UI["klaviatuur"]) + beg_lines.append(S.BEG_UI["balance"].format(balance=coin(res["balance"]))) + embed = discord.Embed(title=title, description="\n".join(beg_lines), color=color) + await interaction.response.send_message(embed=embed) + asyncio.create_task(maybe_remind(interaction.user.id, "beg")) + asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["beg"])) + + @tree.command(name="crime", description=S.CMD["crime"]) + async def cmd_crime(interaction: discord.Interaction): + if await check_cmd_rate(interaction): + return + res = await economy.do_crime(interaction.user.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["crime"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + return + + if res["success"]: + crime_lines = [S.CRIME_UI["win_desc"].format(text=res["text"], earned=coin(res["earned"]))] + if res["mikrofon"]: + crime_lines.append(S.CRIME_UI["mikrofon"].lstrip("\n")) + crime_lines.append(S.CRIME_UI["balance"].lstrip("\n").format(balance=coin(res["balance"]))) + embed = discord.Embed( + title=S.TITLE["crime_win"], + description="\n".join(crime_lines), + color=0x57F287, + ) + else: + jail_part = ( + S.CRIME_UI["fail_jailed"].format(ts=cd_ts(economy.JAIL_DURATION)) + if res.get("jailed") + else S.CRIME_UI["fail_shield"] + ) + embed = discord.Embed( + title=S.TITLE["crime_fail"], + description=S.CRIME_UI["fail_base"].format(text=res["text"], fine=coin(res["fine"])) + + jail_part + + S.CRIME_UI["balance"].format(balance=coin(res["balance"])), + color=0xED4245, + ) + await interaction.response.send_message(embed=embed) + asyncio.create_task(maybe_remind(interaction.user.id, "crime")) + if res["success"]: + asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["crime_win"])) + + @tree.command(name="rob", description=S.CMD["rob"]) + async def cmd_rob(interaction: discord.Interaction, sihtmärk: discord.Member): + if await check_cmd_rate(interaction): + return + if sihtmärk.id == interaction.user.id: + await interaction.response.send_message(S.ERR["rob_self"], ephemeral=True) + return + if sihtmärk.bot and (bot.user is None or sihtmärk.id != bot.user.id): + await interaction.response.send_message(S.ERR["rob_bot"], ephemeral=True) + return + if bot.user and sihtmärk.id == bot.user.id: + await interaction.response.send_message(S.ERR["rob_house_blocked"], ephemeral=True) + return + + res = await economy.do_rob(interaction.user.id, sihtmärk.id) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "cooldown": + await interaction.response.send_message( + S.CD_MSG["rob"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + elif res["reason"] == "jailed": + await interaction.response.send_message( + S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), + ephemeral=True, + ) + elif res["reason"] == "broke": + await interaction.response.send_message( + S.ERR["rob_too_poor"].format(name=sihtmärk.display_name), + ephemeral=True, + ) + elif res["reason"] == "target_jailed": + await interaction.response.send_message( + S.ERR["rob_target_jailed"].format(name=sihtmärk.display_name), + ephemeral=True, + ) + return + + if res["success"]: + if res.get("jackpot"): + desc = S.ROB_UI["jackpot_desc"].format(stolen=coin(res["stolen"]), balance=coin(res["balance"])) + color = 0xF4C430 + else: + desc = S.ROB_UI["win_desc"].format( + stolen=coin(res["stolen"]), + name=sihtmärk.display_name, + balance=coin(res["balance"]), + ) + color = 0x57F287 + embed = discord.Embed(title=S.TITLE["rob_win"], description=desc, color=color) + elif res["reason"] == "valvur": + embed = discord.Embed( + title=S.TITLE["rob_anticheat"], + description=S.ROB_UI["anticheat_desc"].format( + name=sihtmärk.display_name, + fine=coin(res["fine"]), + ), + color=0xED4245, + ) + target_data = await economy.get_user(sihtmärk.id) + if "anticheat" not in target_data.get("items", []): + try: + await sihtmärk.send(S.ROB_UI["anticheat_worn"]) + except discord.Forbidden: + pass + else: + embed = discord.Embed( + title=S.TITLE["rob_fail"], + description=S.ROB_UI["fail_desc"].format( + fine=coin(res["fine"]), + balance=coin(res["balance"]), + ), + color=0xED4245, + ) + await interaction.response.send_message(embed=embed) + asyncio.create_task(maybe_remind(interaction.user.id, "rob")) + if res["success"]: + asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["rob_win"])) + try: + await sihtmärk.send( + S.ROB_UI["victim_dm"].format( + robber=interaction.user.display_name, + stolen=coin(res["stolen"]), + ) + ) + except discord.Forbidden: + pass diff --git a/economy_profile_commands.py b/economy_profile_commands.py new file mode 100644 index 0000000..1934d49 --- /dev/null +++ b/economy_profile_commands.py @@ -0,0 +1,545 @@ +from __future__ import annotations + +import asyncio +import datetime +from collections.abc import Awaitable, Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_economy_profile_commands( + tree: app_commands.CommandTree, + coin: Callable[[int], str], + cd_ts: Callable[[datetime.timedelta], str], + ensure_level_role: Callable[[discord.Member, int], Awaitable[None]], +) -> None: + def _profile_main_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed: + exp = data.get("exp", 0) + level = economy.get_level(exp) + role_name = economy.level_role_name(level) + next_level = level + 1 + exp_this = economy.exp_for_level(level) + exp_next = economy.exp_for_level(next_level) + progress = exp - exp_this + needed = exp_next - exp_this + pct = progress / needed if needed > 0 else 1.0 + filled = int(pct * 12) + bar = "█" * filled + "░" * (12 - filled) + embed = discord.Embed( + title=S.PROFILE_UI["main_title"].format(name=target.display_name), + color=0xF4C430, + ) + embed.add_field(name=S.PROFILE_UI["f_balance"], value=coin(data.get("balance", 0)), inline=True) + embed.add_field( + name=S.PROFILE_UI["f_level"], + value=S.PROFILE_UI["level_val"].format(level=level, role=role_name), + inline=True, + ) + streak = data.get("daily_streak", 0) + if streak: + embed.add_field( + name=S.PROFILE_UI["f_streak"], + value=S.BALANCE_UI["streak_val"].format(streak=streak), + inline=True, + ) + p_level = data.get("prestige_level", 0) + if p_level > 0: + p_pp = data.get("prestige_points", 0) + embed.add_field( + name=S.PROFILE_UI["f_prestige"], + value=S.PROFILE_UI["prestige_val"].format(level=p_level, pp=p_pp), + inline=True, + ) + jail_remaining = economy._is_jailed(data) + if jail_remaining: + embed.add_field(name=S.PROFILE_UI["f_jail"], value=cd_ts(jail_remaining), inline=True) + embed.add_field( + name=S.PROFILE_UI["f_progress"].format(next=next_level), + value=S.PROFILE_UI["progress_bar"].format(bar=bar, done=progress, needed=needed), + inline=False, + ) + if level < 10: + embed.set_footer(text=S.PROFILE_UI["footer_t1"]) + elif level < 20: + embed.set_footer(text=S.PROFILE_UI["footer_t2"]) + else: + embed.set_footer(text=S.PROFILE_UI["footer_t3"]) + return embed + + def _profile_items_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed: + embed = discord.Embed( + title=S.PROFILE_UI["items_title"].format(name=target.display_name), + color=0xF4C430, + ) + uses_map = data.get("item_uses", {}) + item_lines = [] + for item_id in data.get("items", []): + if item_id not in economy.SHOP: + continue + line = f"{economy.SHOP[item_id]['emoji']} **{economy.SHOP[item_id]['name']}**" + if item_id in uses_map: + uses = uses_map[item_id] + line += ( + S.BALANCE_UI["uses_one"].format(uses=uses) + if uses == 1 + else S.BALANCE_UI["uses_many"].format(uses=uses) + ) + item_lines.append(line) + embed.description = "\n".join(item_lines) if item_lines else S.PROFILE_UI["items_empty"] + return embed + + def _profile_stats_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed: + def _s(key: str) -> int: + return data.get(key, 0) + + embed = discord.Embed( + title=S.PROFILE_UI["stats_title"].format(name=target.display_name), + color=0x5865F2, + ) + embed.add_field( + name=S.STATS_UI["economy_field"], + value=S.STATS_UI["economy_val"].format( + peak=coin(_s("peak_balance")), + earned=coin(_s("lifetime_earned")), + lost=coin(_s("lifetime_lost")), + ), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["work_field"], + value=S.STATS_UI["work_val"].format(work=_s("work_count"), beg=_s("beg_count")), + inline=True, + ) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field( + name=S.STATS_UI["gamble_field"], + value=S.STATS_UI["gamble_val"].format( + wagered=coin(_s("total_wagered")), + win=coin(_s("biggest_win")), + loss=coin(_s("biggest_loss")), + jackpots=_s("slots_jackpots"), + ), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["crime_field"], + value=S.STATS_UI["crime_val"].format( + crimes=_s("crimes_attempted"), + succeeded=_s("crimes_succeeded"), + heists=_s("heists_joined"), + heists_won=_s("heists_won"), + jailed=_s("times_jailed"), + bail=coin(_s("total_bail_paid")), + ), + inline=True, + ) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field( + name=S.STATS_UI["social_field"], + value=S.STATS_UI["social_val"].format( + given=coin(_s("total_given")), + received=coin(_s("total_received")), + ), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["records_field"], + value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")), + inline=True, + ) + return embed + + def _profile_fish_embed(target: discord.User | discord.Member, fish_res: dict) -> discord.Embed: + embed = discord.Embed( + title=S.PROFILE_UI["fish_title"].format(name=target.display_name), + color=0x5865F2, + ) + book: dict = fish_res["book"] + if not book: + embed.description = S.FISH_UI["book_empty"] + return embed + inv_counts: dict = fish_res.get("inv_counts", {}) + caught_count = fish_res["unique_caught"] + total = fish_res["total_species"] + lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)] + for fish_id, fish_data in economy.FISH_CATALOGUE.items(): + rarity = fish_data["rarity"] + emoji = S.FISH_RARITY_EMOJI[rarity] + rarity_name = S.FISH_RARITY_NAMES[rarity] + count = book.get(fish_id, 0) + if count > 0: + n_inv = inv_counts.get(fish_id, 0) + inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else "" + lines.append( + S.FISH_UI["book_yes"].format( + emoji=emoji, + name=S.FISH_NAMES[fish_id], + rarity=rarity_name, + count=count, + inv=inv_str, + ) + ) + else: + lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name)) + embed.description = "\n".join(lines) + embed.set_footer( + text=S.FISH_UI["book_footer"].format( + page=1, + total_pages=1, + caught=caught_count, + total=total, + ) + ) + return embed + + class ProfileView(discord.ui.View): + def __init__(self, target: discord.User | discord.Member, invoker_id: int, tab: str = "main"): + super().__init__(timeout=120) + self.target = target + self.invoker_id = invoker_id + self.tab = tab + self._rebuild() + + def _rebuild(self): + self.clear_items() + tabs = [ + ("main", S.PROFILE_UI["btn_profile"]), + ("items", S.PROFILE_UI["btn_items"]), + ("stats", S.PROFILE_UI["btn_stats"]), + ("fish", S.PROFILE_UI["btn_fish"]), + ] + for tab_id, label in tabs: + btn = discord.ui.Button( + label=label, + style=( + discord.ButtonStyle.primary + if tab_id == self.tab + else discord.ButtonStyle.secondary + ), + disabled=(tab_id == self.tab), + ) + btn.callback = self._make_cb(tab_id) + self.add_item(btn) + + def _make_cb(self, tab_id: str): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.invoker_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + self.tab = tab_id + self._rebuild() + await interaction.response.defer() + data = await economy.get_user(self.target.id) + if tab_id == "fish": + fish_res = await economy.do_fishbook(self.target.id) + embed = _profile_fish_embed(self.target, fish_res) + inv: list = data.get("fish_inventory") or [] + if inv and self.target.id == self.invoker_id: + total_value = sum(entry.get("value", 0) for entry in inv) + sell_btn = discord.ui.Button( + label=( + f"{S.FISH_UI['btn_sell']} " + f"({len(inv)} kala · {total_value:,} {economy.COIN})" + ), + style=discord.ButtonStyle.success, + row=1, + ) + sell_btn.callback = self._sell_fish_cb() + self.add_item(sell_btn) + elif tab_id == "items": + embed = _profile_items_embed(self.target, data) + elif tab_id == "stats": + embed = _profile_stats_embed(self.target, data) + else: + embed = _profile_main_embed(self.target, data) + await interaction.edit_original_response(embed=embed, view=self) + + return _cb + + def _sell_fish_cb(self): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.invoker_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + await interaction.response.defer() + res = await economy.do_fish_sell(self.invoker_id) + self.tab = "fish" + self._rebuild() + fish_res = await economy.do_fishbook(self.target.id) + embed = _profile_fish_embed(self.target, fish_res) + sold_line = S.FISH_UI["inv_sold_all"].format( + count=res.get("count", 0), + coins=coin(res.get("coins", 0)), + balance=coin(res.get("balance", 0)), + ) + embed.description = f"{sold_line}\n\n{embed.description or ''}" + await interaction.edit_original_response(embed=embed, view=self) + + return _cb + + def _balance_embed(user: discord.User | discord.Member, data: dict) -> discord.Embed: + embed = discord.Embed( + title=f"{economy.COIN} {user.display_name}", + color=0xF4C430, + ) + embed.add_field(name=S.BALANCE_UI["saldo"], value=coin(data["balance"]), inline=True) + streak = data.get("daily_streak", 0) + if streak: + embed.add_field( + name=S.BALANCE_UI["streak"], + value=S.BALANCE_UI["streak_val"].format(streak=streak), + inline=True, + ) + jail_remaining = economy._is_jailed(data) + if jail_remaining: + embed.add_field(name=S.BALANCE_UI["jailed_until"], value=cd_ts(jail_remaining), inline=True) + item_lines = [] + uses_map = data.get("item_uses", {}) + for item_id in data.get("items", []): + if item_id not in economy.SHOP: + continue + line = f"{economy.SHOP[item_id]['emoji']} {economy.SHOP[item_id]['name']}" + if item_id in uses_map: + uses = uses_map[item_id] + line += ( + S.BALANCE_UI["uses_one"].format(uses=uses) + if uses == 1 + else S.BALANCE_UI["uses_many"].format(uses=uses) + ) + item_lines.append(line) + if item_lines: + embed.add_field(name=S.BALANCE_UI["items"], value="\n".join(item_lines), inline=False) + return embed + + @tree.command(name="profile", description=S.CMD["profile"]) + @app_commands.describe(kasutaja=S.OPT["profile_kasutaja"]) + async def cmd_profile(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + data = await economy.get_user(target.id) + embed = _profile_main_embed(target, data) + invoker_id = interaction.user.id + await interaction.response.send_message(embed=embed, view=ProfileView(target, invoker_id)) + if not kasutaja and interaction.guild: + member = interaction.guild.get_member(target.id) + if member: + asyncio.create_task(ensure_level_role(member, economy.get_level(data.get("exp", 0)))) + + @tree.command(name="balance", description=S.CMD["balance"]) + async def cmd_balance(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + data = await economy.get_user(target.id) + await interaction.response.send_message(embed=_balance_embed(target, data)) + + @tree.command(name="cooldowns", description=S.CMD["cooldowns"]) + async def cmd_cooldowns(interaction: discord.Interaction): + data = await economy.get_user(interaction.user.id) + now = datetime.datetime.now(datetime.timezone.utc) + items = set(data.get("items", [])) + + def _status(last_key: str, cooldown: datetime.timedelta) -> str: + raw = data.get(last_key) + if not raw: + return S.COOLDOWNS_UI["ready"] + last = economy._parse_dt(raw) + if last is None: + return S.COOLDOWNS_UI["ready"] + expires = last + cooldown + if expires <= now: + return S.COOLDOWNS_UI["ready"] + ts = int(expires.timestamp()) + return f"⏳ " + + work_cd = datetime.timedelta(minutes=40) if "monitor" in items else economy.COOLDOWNS["work"] + beg_cd = datetime.timedelta(minutes=3) if "hiirematt" in items else economy.COOLDOWNS["beg"] + daily_cd = datetime.timedelta(hours=18) if "korvaklapid" in items else economy.COOLDOWNS["daily"] + fish_cd = datetime.timedelta(seconds=90) if "ussipurk" in items else economy.COOLDOWNS["fish"] + + lines = [ + S.COOLDOWNS_UI["daily_line"].format( + status=_status("last_daily", daily_cd), + note=S.COOLDOWNS_UI["note_korvak"] if "korvaklapid" in items else "", + ), + S.COOLDOWNS_UI["work_line"].format( + status=_status("last_work", work_cd), + note=S.COOLDOWNS_UI["note_monitor"] if "monitor" in items else "", + ), + S.COOLDOWNS_UI["beg_line"].format( + status=_status("last_beg", beg_cd), + note=S.COOLDOWNS_UI["note_hiirematt"] if "hiirematt" in items else "", + ), + S.COOLDOWNS_UI["crime_line"].format(status=_status("last_crime", economy.COOLDOWNS["crime"])), + S.COOLDOWNS_UI["rob_line"].format(status=_status("last_rob", economy.COOLDOWNS["rob"])), + S.COOLDOWNS_UI["fish_line"].format( + status=_status("last_fish", fish_cd), + note=S.COOLDOWNS_UI["note_ussipurk"] if "ussipurk" in items else "", + ), + ] + + jailed = data.get("jailed_until") + if jailed: + jail_dt = datetime.datetime.fromisoformat(jailed) + if jail_dt > now: + ts = int(jail_dt.timestamp()) + lines.append(S.COOLDOWNS_UI["jailed"].format(ts=ts)) + else: + lines.append(S.COOLDOWNS_UI["jail_expired"]) + + embed = discord.Embed( + title=S.TITLE["cooldowns"], + description="\n".join(lines), + color=0x5865F2, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @tree.command(name="jailed", description=S.CMD["jailed"]) + @app_commands.guild_only() + async def cmd_jailed(interaction: discord.Interaction): + await interaction.response.defer() + jailed = await economy.do_get_jailed() + if not jailed: + embed = discord.Embed( + title=S.JAILED_UI["title"], + description=S.JAILED_UI["empty"], + color=0x57F287, + ) + await interaction.followup.send(embed=embed) + return + + now = datetime.datetime.now(datetime.timezone.utc) + lines = [] + for uid, remaining in jailed: + ts = int((now + remaining).timestamp()) + member = interaction.guild.get_member(uid) if interaction.guild else None + mention = member.mention if member else f"<@{uid}>" + lines.append(S.JAILED_UI["entry"].format(mention=mention, ts=ts)) + + plural = "" if len(jailed) == 1 else "i" + embed = discord.Embed( + title=S.JAILED_UI["title"], + description="\n".join(lines), + color=0xED4245, + ) + embed.set_footer(text=S.JAILED_UI["footer"].format(count=len(jailed), plural=plural)) + await interaction.followup.send(embed=embed) + + @tree.command(name="rank", description=S.CMD["rank"]) + @app_commands.describe(kasutaja=S.OPT["rank_kasutaja"]) + async def cmd_rank(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + data = await economy.get_user(target.id) + exp = data.get("exp", 0) + level = economy.get_level(exp) + role_name = economy.level_role_name(level) + next_level = level + 1 + exp_this = economy.exp_for_level(level) + exp_next = economy.exp_for_level(next_level) + progress = exp - exp_this + needed = exp_next - exp_this + pct = progress / needed if needed > 0 else 1.0 + filled = int(pct * 12) + bar = "█" * filled + "░" * (12 - filled) + embed = discord.Embed( + title=S.RANK_UI["title"].format(name=target.display_name, level=level), + color=0x5865F2, + ) + embed.add_field(name=S.RANK_UI["field_title"], value=f"**{role_name}**", inline=True) + embed.add_field(name=S.RANK_UI["field_exp"], value=str(exp), inline=True) + embed.add_field( + name=S.RANK_UI["field_progress"].format(next=next_level), + value=S.RANK_UI["progress_val"].format(bar=bar, progress=progress, needed=needed), + inline=False, + ) + p_level = data.get("prestige_level", 0) + p_pp = data.get("prestige_points", 0) + s_exp = data.get("season_total_exp", 0) + if p_level > 0 or s_exp > 0: + embed.add_field( + name="\u200b", + value=( + S.PRESTIGE_UI["rank_line"].format(level=p_level, pp=p_pp) + + "\n" + + S.PRESTIGE_UI["rank_season"].format(exp=s_exp) + ), + inline=False, + ) + if level < 10: + embed.set_footer(text=S.RANK_UI["footer_t1"]) + elif level < 20: + embed.set_footer(text=S.RANK_UI["footer_t2"]) + else: + embed.set_footer(text=S.RANK_UI["footer_t3"]) + await interaction.response.send_message(embed=embed, ephemeral=True) + if not kasutaja and interaction.guild: + member = interaction.guild.get_member(target.id) + if member: + asyncio.create_task(ensure_level_role(member, level)) + + @tree.command(name="stats", description=S.CMD["stats"]) + @app_commands.describe(kasutaja=S.OPT["stats_kasutaja"]) + async def cmd_stats(interaction: discord.Interaction, kasutaja: discord.Member | None = None): + target = kasutaja or interaction.user + data = await economy.get_user(target.id) + + def _s(key: str) -> int: + return data.get(key, 0) + + embed = discord.Embed( + title=f"{S.TITLE['stats']} - {target.display_name}", + color=0x5865F2, + ) + embed.add_field( + name=S.STATS_UI["economy_field"], + value=S.STATS_UI["economy_val"].format( + peak=coin(_s("peak_balance")), + earned=coin(_s("lifetime_earned")), + lost=coin(_s("lifetime_lost")), + ), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["work_field"], + value=S.STATS_UI["work_val"].format(work=_s("work_count"), beg=_s("beg_count")), + inline=True, + ) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field( + name=S.STATS_UI["gamble_field"], + value=S.STATS_UI["gamble_val"].format( + wagered=coin(_s("total_wagered")), + win=coin(_s("biggest_win")), + loss=coin(_s("biggest_loss")), + jackpots=_s("slots_jackpots"), + ), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["crime_field"], + value=S.STATS_UI["crime_val"].format( + crimes=_s("crimes_attempted"), + succeeded=_s("crimes_succeeded"), + heists=_s("heists_joined"), + heists_won=_s("heists_won"), + jailed=_s("times_jailed"), + bail=coin(_s("total_bail_paid")), + ), + inline=True, + ) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field( + name=S.STATS_UI["social_field"], + value=S.STATS_UI["social_val"].format( + given=coin(_s("total_given")), + received=coin(_s("total_received")), + ), + inline=True, + ) + embed.add_field( + name=S.STATS_UI["records_field"], + value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")), + inline=True, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) diff --git a/economy_support_commands.py b/economy_support_commands.py new file mode 100644 index 0000000..1a66800 --- /dev/null +++ b/economy_support_commands.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from collections.abc import Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_economy_support_commands( + tree: app_commands.CommandTree, + parse_amount: Callable[[str, int], tuple[int | None, str | None]], + coin: Callable[[int], str], + cancel_reminder_task: Callable[[int, str], None], +) -> None: + class FundModal(discord.ui.Modal): + summa = discord.ui.TextInput( + label=S.REQUEST_UI["modal_label"], + min_length=1, + max_length=10, + ) + + def __init__(self, view: "RequestView"): + super().__init__(title=S.REQUEST_UI["modal_title"]) + self._view = view + self.summa.placeholder = f"1 - {view.remaining}" + + async def on_submit(self, interaction: discord.Interaction): + amount, err = parse_amount(self.summa.value, 0) + if err or amount is None: + await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True) + return + if amount <= 0 or amount > self._view.remaining: + await interaction.response.send_message( + S.ERR["fund_range"].format(max=self._view.remaining), ephemeral=True + ) + return + + res = await economy.do_give(interaction.user.id, self._view.requester.id, amount) + if not res["ok"]: + data = await economy.get_user(interaction.user.id) + await interaction.response.send_message( + S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True + ) + return + + self._view.remaining -= amount + funded_line = S.REQUEST_UI["funded_line"].format( + name=interaction.user.display_name, + amount=coin(amount), + ) + if self._view.remaining <= 0: + self._view.fund_btn.disabled = True + self._view.fund_btn.label = S.REQUEST_UI["btn_funded"] + self._view.fund_btn.style = discord.ButtonStyle.secondary + self._view.stop() + funded_line += S.REQUEST_UI["funded_full"] + else: + self._view.fund_btn.label = S.REQUEST_UI["btn_fund_remaining"].format( + remaining=self._view.remaining + ) + funded_line += S.REQUEST_UI["funded_partial"].format( + remaining=coin(self._view.remaining) + ) + + await interaction.response.send_message(funded_line) + if self._view.message: + await self._view.message.edit(view=self._view) + + class RequestView(discord.ui.View): + def __init__(self, requester: discord.Member, amount: int, target: discord.Member | None): + super().__init__(timeout=300) + self.requester = requester + self.remaining = amount + self.target = target + self.message: discord.Message | None = None + self.fund_btn = discord.ui.Button( + label=S.REQUEST_UI["btn_fund"], + style=discord.ButtonStyle.success, + ) + self.fund_btn.callback = self._fund + self.add_item(self.fund_btn) + + async def _fund(self, interaction: discord.Interaction): + if interaction.user.id == self.requester.id: + await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True) + return + if self.target and interaction.user.id != self.target.id: + await interaction.response.send_message( + S.ERR["request_targeted"].format(name=self.target.display_name), + ephemeral=True, + ) + return + await interaction.response.send_modal(FundModal(self)) + + async def on_timeout(self): + for item in self.children: + item.disabled = True + + max_request = 1_000_000 + + @tree.command(name="request", description=S.CMD["request"]) + @app_commands.describe( + summa=S.OPT["request_summa"], + põhjus=S.OPT["request_põhjus"], + sihtmärk=S.OPT["request_sihtmärk"], + ) + async def cmd_request( + interaction: discord.Interaction, + summa: str, + põhjus: str, + sihtmärk: discord.Member | None = None, + ): + summa_int, err = parse_amount(summa, 0) + if err or summa_int is None: + await interaction.response.send_message(err or S.ERR["invalid_amount"], ephemeral=True) + return + if summa_int <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + if summa_int > max_request: + await interaction.response.send_message( + S.ERR["fund_range"].format(max=coin(max_request)), + ephemeral=True, + ) + return + summa = summa_int + if sihtmärk and sihtmärk.id == interaction.user.id: + await interaction.response.send_message(S.ERR["request_self"], ephemeral=True) + return + if sihtmärk and sihtmärk.bot: + await interaction.response.send_message(S.ERR["request_bot"], ephemeral=True) + return + + audience = ( + S.REQUEST_UI["audience_targeted"].format(name=sihtmärk.display_name) + if sihtmärk + else S.REQUEST_UI["audience_all"] + ) + embed = discord.Embed( + title=S.TITLE["request"], + description=S.REQUEST_UI["desc"].format( + requester=interaction.user.display_name, + amount=coin(summa), + reason=põhjus, + audience=audience, + ), + color=0xF4C430, + ) + embed.set_footer(text=S.REQUEST_UI["footer"]) + view = RequestView(interaction.user, summa, sihtmärk) + await interaction.response.send_message(embed=embed, view=view) + view.message = await interaction.original_response() + + class RemindersSelect(discord.ui.Select): + def __init__(self, user_id: int, current: list[str]): + self.user_id = user_id + options = [ + discord.SelectOption( + label=label, + description=desc, + value=cmd, + default=cmd in current, + ) + for cmd, label, desc in S.REMINDER_OPTS + ] + super().__init__( + placeholder=S.REMINDERS_UI["select_placeholder"], + options=options, + min_values=0, + max_values=len(S.REMINDER_OPTS), + ) + + async def callback(self, interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + await economy.do_set_reminders(self.user_id, self.values) + enabled = set(self.values) + for cmd in [opt[0] for opt in S.REMINDER_OPTS]: + if cmd not in enabled: + cancel_reminder_task(self.user_id, cmd) + if self.values: + names = " ".join(f"`/{v}`" for v in self.values) + msg = S.REMINDERS_UI["saved_on"].format(names=names) + else: + msg = S.REMINDERS_UI["saved_off"] + await interaction.response.send_message(msg, ephemeral=True) + + class RemindersView(discord.ui.View): + def __init__(self, user_id: int, current: list[str]): + super().__init__(timeout=60) + self.add_item(RemindersSelect(user_id, current)) + + @tree.command(name="reminders", description=S.CMD["reminders"]) + async def cmd_reminders(interaction: discord.Interaction): + user_data = await economy.get_user(interaction.user.id) + current = user_data.get("reminders", []) + if current: + status = " ".join(f"`/{c}`" for c in current) + desc = S.REMINDERS_UI["desc_active"].format(status=status) + else: + desc = S.REMINDERS_UI["desc_none"] + embed = discord.Embed( + title=S.TITLE["reminders"], + description=desc, + color=0x5865F2, + ) + embed.set_footer(text=S.REMINDERS_UI["footer"]) + await interaction.response.send_message( + embed=embed, + view=RemindersView(interaction.user.id, current), + ephemeral=True, + ) diff --git a/strings.py b/strings.py index 18cfd63..cb5cfd5 100644 --- a/strings.py +++ b/strings.py @@ -1121,6 +1121,7 @@ JAILBREAK_UI: dict[str, str] = { LEADERBOARD_UI: dict[str, str] = { "house_entry": "🤖 {name} *(maja)* - {balance}", + "house_default_name": "TipiBOT", "no_entries": "Keegi ei ole veel punkte teeninud.", "footer": "Lehekülg {page}/{total} · {count} mängijat", "btn_coins": "🪙 Mündid",