From 472611569f02511d367d385427daaa208c40ba7f Mon Sep 17 00:00:00 2001 From: MAX <63972751+ltzmax@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:02:06 +0200 Subject: [PATCH 01/11] publish this on second branch * not finished yet. --- heist/commands/owner_commands.py | 259 +++++ heist/commands/user_commands.py | 403 ++++++++ heist/handlers.py | 318 +++++- heist/heist.py | 844 ++-------------- heist/meta.py | 78 ++ heist/utils.py | 308 +++++- heist/views.py | 1621 ++++++++++++++++++++++++------ 7 files changed, 2743 insertions(+), 1088 deletions(-) create mode 100644 heist/commands/owner_commands.py create mode 100644 heist/commands/user_commands.py create mode 100644 heist/meta.py diff --git a/heist/commands/owner_commands.py b/heist/commands/owner_commands.py new file mode 100644 index 00000000..27217546 --- /dev/null +++ b/heist/commands/owner_commands.py @@ -0,0 +1,259 @@ +""" +MIT License + +Copyright (c) 2022-present ltzmax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import datetime + +import discord +from redbot.core import commands + +from ..utils import HEISTS, ITEMS, fmt +from ..views import HeistConfigView, ItemPriceConfigView + + +class OwnerCommands: + """Mixin containing all owner-only heistset commands.""" + + @commands.group(with_app_command=False) + @commands.is_owner() + async def heistset(self, ctx): + """Manage global heist settings.""" + + @heistset.command(name="set") + async def heistset_set(self, ctx: commands.Context): + """Configure a heist's parameters. + + Select the heist, then the parameter, then enter the new value. + """ + view = HeistConfigView(self, ctx) + view.message = await ctx.send(view=view, ephemeral=True) + + @heistset.command(name="price") + async def heistset_price(self, ctx: commands.Context): + """Configure item shop prices. + + Select the item and enter the new price. + """ + view = ItemPriceConfigView(self, ctx) + view.message = await ctx.send(view=view, ephemeral=True) + + @heistset.command(name="reset") + async def heistset_reset(self, ctx: commands.Context, heist_type: str | None = None): + """Reset heist settings to default values. + + If no heist_type is provided, resets all heists. + """ + async with self.config.heist_settings() as settings: + if heist_type: + heist_type = heist_type.lower().replace(" ", "_") + if heist_type not in HEISTS: + return await ctx.send(f"Invalid heist type: {heist_type}") + settings[heist_type] = { + "risk": HEISTS[heist_type]["risk"], + "min_reward": HEISTS[heist_type]["min_reward"], + "max_reward": HEISTS[heist_type]["max_reward"], + "cooldown": HEISTS[heist_type]["cooldown"].total_seconds(), + "min_success": HEISTS[heist_type]["min_success"], + "max_success": HEISTS[heist_type]["max_success"], + "duration": HEISTS[heist_type]["duration"].total_seconds(), + "police_chance": HEISTS[heist_type]["police_chance"], + "jail_time": HEISTS[heist_type]["jail_time"].total_seconds(), + } + await ctx.send( + f"Reset settings for {heist_type.replace('_', ' ').title()} to defaults." + ) + else: + settings.clear() + settings.update( + { + name: { + "risk": data["risk"], + "min_reward": data["min_reward"], + "max_reward": data["max_reward"], + "cooldown": data["cooldown"].total_seconds(), + "min_success": data["min_success"], + "max_success": data["max_success"], + "duration": data["duration"].total_seconds(), + "police_chance": data["police_chance"], + "jail_time": data["jail_time"].total_seconds(), + } + for name, data in HEISTS.items() + } + ) + await ctx.send("Reset all heist settings to defaults.") + + @heistset.command(name="resetprice") + async def heistset_resetprice(self, ctx: commands.Context, item_name: str | None = None): + """Reset item prices to default values. + + If no item_name is provided, resets all item prices. + """ + async with self.config.item_settings() as settings: + if item_name: + item_name = item_name.lower().replace(" ", "_") + if item_name not in ITEMS or "cost" not in ITEMS[item_name][1]: + return await ctx.send(f"Invalid item: {item_name}") + settings[item_name] = {"cost": ITEMS[item_name][1]["cost"]} + await ctx.send( + f"Reset price for {item_name.replace('_', ' ').title()} to default." + ) + else: + settings.clear() + settings.update( + { + name: {"cost": data["cost"]} + for name, (_, data) in ITEMS.items() + if "cost" in data + } + ) + await ctx.send("Reset all item prices to defaults.") + + @heistset.command(name="cooldownreset") + async def heistset_cooldownreset( + self, ctx: commands.Context, member: discord.Member, heist_type: str | None = None + ): + """Reset heist cooldowns for a user. + + If no heist_type is provided, resets all cooldowns for that user. + + **Arguments** + - `` The member whose cooldowns to reset. + - `[heist_type]` The specific heist to reset. Omit to reset all. + """ + if heist_type: + heist_type = heist_type.lower().replace(" ", "_") + if heist_type not in HEISTS: + return await ctx.send(f"Invalid heist type: `{heist_type}`.") + async with self.config.user(member).heist_cooldowns() as cooldowns: + cooldowns.pop(heist_type, None) + await ctx.send(f"Reset **{fmt(heist_type)}** cooldown for {member.display_name}.") + else: + await self.config.user(member).heist_cooldowns.set({}) + await ctx.send(f"Reset all heist cooldowns for {member.display_name}.") + + @heistset.command(name="settings") + @commands.bot_has_permissions(embed_links=True) + async def heistset_show(self, ctx: commands.Context, heist_type: str | None = None): + """Show current settings for a heist or all heists.""" + heist_type = heist_type.lower().replace(" ", "_") if heist_type else None + if heist_type and heist_type not in HEISTS: + return await ctx.send(f"Invalid heist type: {heist_type}") + + embed = discord.Embed( + title="Heist Settings", + description=f"Settings for {'all heists' if not heist_type else heist_type.replace('_', ' ').title()} (custom values marked with ⭐)", + color=await ctx.embed_color(), + ) + + heists_to_show = ( + [heist_type] + if heist_type + else [n for n in sorted(HEISTS.keys()) if not HEISTS[n].get("crew_size")] + ) + current_settings = await self.config.heist_settings() + for name in heists_to_show: + data = await self.get_heist_settings(name) + custom = current_settings.get(name, {}) + defaults = HEISTS.get(name, {}) + _timedelta_params = {"cooldown", "duration", "jail_time"} + is_custom = {} + for param in [ + "risk", + "min_reward", + "max_reward", + "cooldown", + "min_success", + "max_success", + "duration", + "police_chance", + "jail_time", + ]: + if param not in custom: + is_custom[param] = False + continue + default_val = defaults.get(param) + if param in _timedelta_params: + default_secs = ( + default_val.total_seconds() + if isinstance(default_val, datetime.timedelta) + else 0.0 + ) + is_custom[param] = custom[param] != default_secs + else: + is_custom[param] = custom[param] != (default_val or 0) + loot_item = name if name in ITEMS and ITEMS[name][1]["type"] == "loot" else None + reward_text = ( + f"{ITEMS[loot_item][1]['min_sell']:,}-{ITEMS[loot_item][1]['max_sell']:,} credits" + if loot_item + else f"{data['min_reward']:,}-{data['max_reward']:,} credits{' ⭐' if is_custom['min_reward'] or is_custom['max_reward'] else ''}" + ) + field_value = ( + f"Reward: {reward_text}\n" + f"Risk: {data['risk'] * 100:.0f}%{' ⭐' if is_custom['risk'] else ''}\n" + f"Success: {data['min_success']}-{data['max_success']}%{' ⭐' if is_custom['min_success'] or is_custom['max_success'] else ''}\n" + f"Cooldown: {data['cooldown'].total_seconds() / 3600:.1f}h{' ⭐' if is_custom['cooldown'] else ''}\n" + f"Duration: {int(data['duration'].total_seconds() // 60)} min{' ⭐' if is_custom['duration'] else ''}\n" + f"Police Chance: {data['police_chance'] * 100:.0f}%{' ⭐' if is_custom['police_chance'] else ''}\n" + f"Jail Time: {data['jail_time'].total_seconds() / 3600:.1f}h{' ⭐' if is_custom['jail_time'] else ''}\n" + f"Loss: {data['min_loss']:,}-{data['max_loss']:,} credits" + ) + embed.add_field( + name=f"{data['emoji']} {name.replace('_', ' ').title()}", + value=field_value, + inline=True, + ) + await ctx.send(embed=embed) + + @heistset.command(name="showprices") + @commands.bot_has_permissions(embed_links=True) + async def heistset_showprices(self, ctx: commands.Context, item_name: str | None = None): + """Show current prices for an item or all shop items.""" + item_name = item_name.lower().replace(" ", "_") if item_name else None + shop_items = {name: data for name, (_, data) in ITEMS.items() if "cost" in data} + if item_name and item_name not in shop_items: + return await ctx.send(f"Invalid item: {item_name}") + + embed = discord.Embed( + title="Shop Item Prices", + description=f"Prices for {'all items' if not item_name else item_name.replace('_', ' ').title()} (custom values marked with ⭐)", + color=await ctx.embed_color(), + ) + embed.set_footer( + text="Use [p]heistset price to modify values or [p]heistset resetprice to revert to defaults." + ) + + items_to_show = [item_name] if item_name else sorted(shop_items.keys()) + current_settings = await self.config.item_settings() + for name in items_to_show: + data = shop_items[name] + custom = current_settings.get(name, {}) + default_cost = data["cost"] + is_custom = "cost" in custom and custom["cost"] != default_cost + cost = custom.get("cost", default_cost) + emoji = ITEMS[name][0] + embed.add_field( + name=f"{emoji} {name.replace('_', ' ').title()}", + value=f"Cost: {cost:,}{' ⭐' if is_custom else ''}", + inline=True, + ) + await ctx.send(embed=embed) diff --git a/heist/commands/user_commands.py b/heist/commands/user_commands.py new file mode 100644 index 00000000..5b90bf74 --- /dev/null +++ b/heist/commands/user_commands.py @@ -0,0 +1,403 @@ +""" +MIT License + +Copyright (c) 2022-present ltzmax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import datetime +import random + +import discord +from redbot.core import bank, commands + +from ..utils import HEISTS, ITEMS, fmt +from ..views import CraftView, CrewLobbyView, EquipView, HeistSelectionView, ShopView + + +def _heat_bar(heat: int, max_heat: int = 20, length: int = 20) -> str: + """Render heat as a dot progress bar.""" + filled = min(round(heat / max_heat * length), length) + bar = "●" * filled + "○" * (length - filled) + pct = min(heat / max_heat * 100, 100) + return f"`{bar}` {pct:.0f}%" + + +class UserCommands: + """Mixin containing all player-facing heist commands.""" + + @commands.hybrid_group() + @commands.guild_only() + async def heist(self, ctx): + """Heist game.""" + + @heist.command(name="equip") + @commands.bot_has_permissions(embed_links=True, send_messages=True) + async def heist_equip(self, ctx: commands.Context): + """Equip or unequip items from your inventory. + + Shows only items you currently own. Each slot (shield, tool) + has its own select menu. Use the unequip buttons to clear a slot. + """ + if not await self.check_jail(ctx, ctx.author): + return + if await self._has_active_heist(ctx.author, ctx.channel.id): + return await ctx.send( + "You have an active heist ongoing. You can't equip during a heist." + ) + + inventory = await self.config.user(ctx.author).inventory() + equipped = await self.config.user(ctx.author).equipped() + view = EquipView(self, ctx, inventory, equipped) + view.message = await ctx.send(view=view) + + @heist.command(name="shop", aliases=["shopping"]) + @commands.bot_has_permissions(embed_links=True) + async def buy_item(self, ctx: commands.Context): + """Buy items like shields or tools to aid in heists.""" + if not await self.check_jail(ctx, ctx.author): + return + if not await self.check_debt(ctx): + return + if await self._has_active_heist(ctx.author, ctx.channel.id): + return await ctx.send("You cannot go shopping while on a heist.") + + currency_name = await bank.get_currency_name(ctx.guild) + costs = { + name: await self.get_item_cost(name) + for name, (_, data) in ITEMS.items() + if "cost" in data + } + view = ShopView(self, ctx, currency_name, costs) + view.message = await ctx.send(view=view) + + @heist.command(name="start") + @commands.bot_has_permissions(embed_links=True) + async def do_heist(self, ctx: commands.Context): + """Attempt a heist to steal items.""" + if not await self.check_debt(ctx): + return + if not await self.check_jail(ctx, ctx.author): + return + if await self._has_active_heist(ctx.author, ctx.channel.id): + return await ctx.send("You have an active heist ongoing. Wait for it to finish.") + + equipped = await self.config.user(ctx.author).equipped() + inventory = await self.config.user(ctx.author).inventory() + warnings = [] + for item_type in ["shield", "tool", "consumable"]: + equipped_item = equipped[item_type] + if equipped_item and inventory.get(equipped_item, 0) <= 0: + warnings.append( + f"Your equipped {item_type} ({equipped_item.replace('_', ' ').title()}) is out of stock and will not be used." + ) + equipped[item_type] = None + await self.config.user(ctx.author).equipped.set(equipped) + if warnings: + await ctx.send("\n".join(warnings)) + + _raw_settings = await self.config.heist_settings() + heist_settings = {} + for name in HEISTS: + if HEISTS[name].get("crew_size"): + continue + defaults = HEISTS[name] + custom = _raw_settings.get(name, {}) + heist_settings[name] = { + "emoji": defaults.get("emoji", "❓"), + "risk": custom.get("risk", defaults.get("risk", 0.0)), + "min_reward": int(custom.get("min_reward", defaults.get("min_reward", 0))), + "max_reward": int(custom.get("max_reward", defaults.get("max_reward", 0))), + "cooldown": datetime.timedelta( + seconds=custom.get("cooldown", defaults["cooldown"].total_seconds()) + ), + "min_success": int(custom.get("min_success", defaults.get("min_success", 0))), + "max_success": int(custom.get("max_success", defaults.get("max_success", 100))), + "duration": datetime.timedelta( + seconds=custom.get("duration", defaults["duration"].total_seconds()) + ), + "min_loss": defaults.get("min_loss", 0), + "max_loss": defaults.get("max_loss", 0), + "police_chance": custom.get("police_chance", defaults.get("police_chance", 0.0)), + "material_drop_chance": defaults.get("material_drop_chance", 0.0), + "jail_time": datetime.timedelta( + seconds=custom.get("jail_time", defaults["jail_time"].total_seconds()) + ), + } + + currency_name = await bank.get_currency_name(ctx.guild) + view = HeistSelectionView(self, ctx, heist_settings, currency_name) + view.message = await ctx.send(view=view) + + @heist.command(name="crew") + @commands.guild_only() + @commands.bot_has_permissions(embed_links=True, send_messages=True) + async def crew_heist(self, ctx: commands.Context): + """Organise a 4-player crew robbery for massive rewards. + + Start a lobby: 3 others must join before you can begin. + Each player's tools and shields apply independently. + Rewards are split equally among all crew members. + """ + if not await self.check_debt(ctx): + return + if not await self.check_jail(ctx, ctx.author): + return + if await self._has_active_heist(ctx.author, ctx.channel.id): + return await ctx.send("You have an active heist ongoing. Wait for it to finish.") + + data = await self.get_heist_settings("crew_robbery") + currency_name = await bank.get_currency_name(ctx.guild) + await self.config.user(ctx.author).active_heist.set( + { + "type": "crew_robbery", + "end_time": datetime.datetime.now(datetime.timezone.utc).timestamp() + + data["duration"].total_seconds(), + "channel_id": ctx.channel.id, + "lobby": True, + } + ) + view = CrewLobbyView(self, ctx, data, currency_name) + view.message = await ctx.send(view=view) + + @heist.command(name="bailout") + async def bailout(self, ctx: commands.Context, member: discord.Member = None): + """Pay bail to get yourself or another user out of jail.""" + jailed_user = member if member else ctx.author + if not await self._is_in_jail(jailed_user): + return await ctx.send(f"{jailed_user.display_name} is not in jail!") + await self.check_jail(ctx, jailed_user) + + @heist.command(name="inventory", aliases=["inv"]) + @commands.bot_has_permissions(embed_links=True) + async def check_inventory(self, ctx: commands.Context): + """Check your stolen items and tools.""" + if not await self.check_jail(ctx, ctx.author): + return + if await self._has_active_heist(ctx.author, ctx.channel.id): + return await ctx.send( + "You have an active heist ongoing. You can't check inventory during heist" + ) + inventory = await self.config.user(ctx.author).inventory() + debt = await self.config.user(ctx.author).debt() + currency_name = await bank.get_currency_name(ctx.guild) + if not inventory and debt <= 0: + return await ctx.send("Your inventory is empty and you have no debt.") + + equipped = await self.config.user(ctx.author).equipped() + sections: dict[str, list[str]] = { + "🛡️ Shields": [], + "🔧 Tools": [], + "💊 Consumables": [], + "💰 Loot": [], + "🧱 Materials": [], + } + + for item, count in inventory.items(): + emoji, data = ITEMS.get(item, ("❓", {"type": "unknown"})) + is_equipped = ( + equipped.get("shield") == item + or equipped.get("tool") == item + or equipped.get("consumable") == item + ) + equipped_tag = " *(equipped)*" if is_equipped else "" + name_str = f"**{emoji} {fmt(item)} ×{count}**{equipped_tag}" + + match data["type"]: + case "shield": + desc = f"-# Reduces loss by {data['reduction'] * 100:.1f}% (single use)" + sections["🛡️ Shields"].append(f"{name_str}\n{desc}\n") + case "tool": + desc = f"-# +{data['boost'] * 100:.0f}% success on {fmt(data['for_heist'])} (single use)" + sections["🔧 Tools"].append(f"{name_str}\n{desc}\n") + case "consumable": + desc = f"-# Reduces risk by {data['risk_reduction'] * 100:.0f}% (single use)" + sections["💊 Consumables"].append(f"{name_str}\n{desc}\n") + case "loot": + desc = f"-# Sell for {data['min_sell']:,}–{data['max_sell']:,} {currency_name}" + sections["💰 Loot"].append(f"{name_str}\n{desc}\n") + case "material": + desc = f"-# Craft or sell for {data['min_sell']:,}–{data['max_sell']:,} {currency_name}" + sections["🧱 Materials"].append(f"{name_str}\n{desc}\n") + case _: + sections["💰 Loot"].append(f"{name_str}\n") + + components: list = [discord.ui.TextDisplay(f"## 🎒 {ctx.author.display_name}'s Inventory")] + + if debt > 0: + components.append(discord.ui.Separator()) + components.append(discord.ui.TextDisplay(f"**💸 Debt:** {debt:,} {currency_name}")) + + for section_name, items in sections.items(): + if items: + components.append(discord.ui.Separator()) + components.append(discord.ui.TextDisplay(f"**{section_name}**\n" + "".join(items))) + + view = discord.ui.LayoutView(timeout=None) + view.add_item(discord.ui.Container(*components)) + await ctx.send(view=view) + + @heist.command(name="sell") + async def sell_item(self, ctx: commands.Context, item: str, amount: int = 1): + """Sell a stolen item or material for currency.""" + if not await self.check_jail(ctx, ctx.author): + return + if await self._has_active_heist(ctx.author, ctx.channel.id): + return await ctx.send( + "You have an active heist ongoing. You can't sell while on heist." + ) + inventory = await self.config.user(ctx.author).inventory() + item = item.lower().replace(" ", "_") + if item not in ITEMS or ITEMS[item][1]["type"] not in ["loot", "material"]: + return await ctx.send("Invalid item type. Only loot and materials can be sold.") + if inventory.get(item, 0) < amount: + return await ctx.send( + f"You don't have enough {item.replace('_', ' ').title()} to sell." + ) + data = ITEMS[item][1] + sell_price = sum(random.randint(data["min_sell"], data["max_sell"]) for _ in range(amount)) + await bank.deposit_credits(ctx.author, sell_price) + inventory[item] -= amount + if inventory[item] <= 0: + del inventory[item] + await self.config.user(ctx.author).inventory.set(inventory) + currency_name = await bank.get_currency_name(ctx.guild) + await ctx.send( + f"Sold {amount} {item.replace('_', ' ').title()} for {sell_price:,} {currency_name}." + ) + + @heist.command(name="craft") + @commands.bot_has_permissions(embed_links=True, send_messages=True) + async def craft_item(self, ctx: commands.Context): + """Craft upgraded shields or tools using materials from heists.""" + if not await self.check_jail(ctx, ctx.author): + return + if await self._has_active_heist(ctx.author, ctx.channel.id): + return await ctx.send( + "You have an active heist ongoing. You can't craft while on heist." + ) + + inventory = await self.config.user(ctx.author).inventory() + view = CraftView(self, ctx, inventory) + view.message = await ctx.send(view=view) + + @heist.command(name="shield") + async def check_shield(self, ctx: commands.Context): + """Check your active shield status.""" + if not await self.check_jail(ctx, ctx.author): + return + equipped = await self.config.user(ctx.author).equipped() + equipped_shield = equipped["shield"] + if equipped_shield: + inventory = await self.config.user(ctx.author).inventory() + count = inventory.get(equipped_shield, 0) + if count > 0: + emoji, data = ITEMS[equipped_shield] + return await ctx.send( + f"Active {emoji} {equipped_shield.replace('_', ' ').title()} shield: " + f"Reduces loss by {data['reduction'] * 100:.1f}% (single use). You have {count}." + ) + await ctx.send("No active shield.") + + @heist.command(name="profile") + async def heist_status(self, ctx: commands.Context): + """Check your active heist profile.""" + active = await self.config.user(ctx.author).active_heist() + jail = await self.config.user(ctx.author).jail() + heat = await self._get_effective_heat(ctx.author) + + lines = [f"## 📊 {ctx.author.display_name}'s Profile"] + + if active: + end_ts = int(active["end_time"]) + lines.append( + f"\n**🎭 Active Heist:** {fmt(active['type'])}\n" + f"Completes ()" + ) + else: + lines.append("\n**🎭 Active Heist:** None") + + if jail: + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + if now < jail["end_time"]: + end_ts = int(jail["end_time"]) + bail = jail["bail_amount"] + tax = int(bail * 0.15) + lines.append( + f"\n**🚨 In Jail** until ()\n" + f"-# Bail: {bail:,} + {tax:,} tax = {bail + tax:,}" + ) + else: + await self.config.user(ctx.author).jail.clear() + lines.append("\n**🚨 Jail:** Free") + else: + lines.append("\n**🚨 Jail:** Free") + + lines.append(f"\n**🌡️ Heat:** {_heat_bar(heat)}") + view = discord.ui.LayoutView(timeout=None) + view.add_item(discord.ui.Container(discord.ui.TextDisplay("\n".join(lines)))) + await ctx.send(view=view) + + @heist.command(name="cooldowns", aliases=["cooldown"]) + @commands.bot_has_permissions(embed_links=True) + async def check_cooldowns(self, ctx: commands.Context): + """Check cooldowns for all heists.""" + if not await self.check_jail(ctx, ctx.author): + return + cooldowns = await self.config.user(ctx.author).heist_cooldowns() + now = datetime.datetime.now(datetime.timezone.utc) + + ready_lines = [] + cd_lines = [] + + for heist_type in sorted(HEISTS.keys(), key=lambda x: fmt(x)): + if HEISTS[heist_type].get("crew_size"): + continue + data = await self.get_heist_settings(heist_type) + last_timestamp = cooldowns.get(heist_type) + heist_label = f"{data['emoji']} {fmt(heist_type)}" + if last_timestamp: + last_time = datetime.datetime.fromtimestamp( + last_timestamp, tz=datetime.timezone.utc + ) + if now - last_time < data["cooldown"]: + remaining = data["cooldown"] - (now - last_time) + end_ts = int((now + remaining).timestamp()) + cd_lines.append(f"**{heist_label}** - ") + continue + ready_lines.append(f"**{heist_label}** - ✅ Ready") + + components: list = [discord.ui.TextDisplay("## ⏱️ Heist Cooldowns")] + + if cd_lines: + components.append(discord.ui.Separator()) + components.append(discord.ui.TextDisplay("**On Cooldown**\n" + "\n".join(cd_lines))) + + if ready_lines: + components.append(discord.ui.Separator()) + components.append(discord.ui.TextDisplay("**Ready**\n" + "\n".join(ready_lines))) + + if not cd_lines and not ready_lines: + components.append(discord.ui.Separator()) + components.append(discord.ui.TextDisplay("All heists are ready!")) + + view = discord.ui.LayoutView(timeout=None) + view.add_item(discord.ui.Container(*components)) + await ctx.send(view=view) diff --git a/heist/handlers.py b/heist/handlers.py index 96756fb0..e4f97312 100644 --- a/heist/handlers.py +++ b/heist/handlers.py @@ -23,6 +23,7 @@ """ import asyncio +import contextlib import datetime import random @@ -30,12 +31,59 @@ from red_commons.logging import getLogger from redbot.core import bank, errors +from .meta import ( + _CREW_FLAVOUR_CAUGHT, + _CREW_FLAVOUR_FAIL, + _CREW_FLAVOUR_SUCCESS, + _FLAVOUR_CAUGHT, + _FLAVOUR_FAIL, + _FLAVOUR_MATERIAL, + _FLAVOUR_SHIELD, + _FLAVOUR_SUCCESS, + _FLAVOUR_TOOL, +) from .utils import ITEMS, fmt log = getLogger("red.cogs.heist.handlers") +def _build_result_view( + heist_type: str, + heist_emoji: str, + member_mention: str, + success: bool, + caught: bool, + msg_parts: list[str], + colour: int, +) -> discord.ui.LayoutView: + """Components v2 LayoutView for a heist result.""" + if caught: + narrative = random.choice(_FLAVOUR_CAUGHT) + elif success: + narrative = random.choice(_FLAVOUR_SUCCESS) + else: + narrative = random.choice(_FLAVOUR_FAIL) + + status = "✅ Success" if success else "❌ Failed" + if caught: + status += " - 🚨 Caught" + + header = f"## {heist_emoji} {fmt(heist_type)} - {status}\n{member_mention}\n*{narrative}*" + + components: list = [ + discord.ui.TextDisplay(header), + ] + + if msg_parts: + components.append(discord.ui.Separator()) + components.append(discord.ui.TextDisplay("\n".join(msg_parts))) + + view = discord.ui.LayoutView(timeout=None) + view.add_item(discord.ui.Container(*components, accent_colour=discord.Colour(colour))) + return view + + async def schedule_resolve( cog, user_id: int, @@ -88,7 +136,7 @@ async def resolve_heist( inventory = await user_config.inventory() equipped = await user_config.equipped() active = await user_config.active_heist() - heat = await user_config.heat() + heat = await cog._get_effective_heat(member) material_heat = await user_config.material_heat() debt = await user_config.debt() tax_agreed = active.get("tax_agreed", False) @@ -126,20 +174,18 @@ async def resolve_heist( if loot_item: inventory[loot_item] = inventory.get(loot_item, 0) + 1 await user_config.inventory.set(inventory) - msg_parts.append( - f"Success! You stole a {fmt(loot_item)} from the {fmt(heist_type)}." - ) + msg_parts.append(f"You stole a **{fmt(loot_item)}** from the {fmt(heist_type)}.") else: reward = random.randint(data["min_reward"], data["max_reward"]) try: await bank.deposit_credits(member, reward) msg_parts.append( - f"Success! You gained {reward:,} {currency_name} from the {fmt(heist_type)}." + f"**+{reward:,} {currency_name}** added to your bank balance." ) except errors.BalanceTooHigh: msg_parts.append( - f"Success! You would have gained {reward:,} {currency_name}, " - "but your balance is too high." + f"You would have gained {reward:,} {currency_name}, " + "but your balance is already at the maximum." ) reward = 0 else: @@ -153,6 +199,7 @@ async def resolve_heist( if inventory[equipped_shield] == 0: inventory.pop(equipped_shield, None) await user_config.inventory.set(inventory) + msg_parts.append(random.choice(_FLAVOUR_SHIELD)) loss = int(loss * (1 - reduction)) @@ -160,30 +207,35 @@ async def resolve_heist( if loss > 0: if balance >= loss: await bank.withdraw_credits(member, loss) - msg_parts.append(f"Failed! You lost {loss:,} {currency_name}.") + msg_parts.append( + f"**−{loss:,} {currency_name}** deducted from your bank balance." + ) else: debt_to_add = loss tax = 0 if tax_agreed: tax = int(loss * 0.2) debt_to_add += tax - new_debt = debt + debt_to_add await user_config.debt.set(new_debt) - msg = f"Failed! You owe {debt_to_add:,} {currency_name} in debt" + debt_msg = f"**{debt_to_add:,} {currency_name}** added to your debt" if tax > 0: - msg += f" (incl. {tax:,} tax)" - msg += "." - msg_parts.append(msg) + debt_msg += f" (incl. {tax:,} tax)" + debt_msg += f". Total debt: **{new_debt:,}**." + msg_parts.append(debt_msg) else: - msg_parts.append("Failed — but your shield prevented any loss.") + msg_parts.append("Your shield absorbed everything. No losses this time.") if used_tool: - msg_parts.append(f"(Used {fmt(used_tool)} → +{tool_boost * 100:.0f}% success chance)") + msg_parts.append( + f"{random.choice(_FLAVOUR_TOOL)}\n" + f"-# {fmt(used_tool)} used (+{tool_boost * 100:.0f}% success boost)" + ) heat += 1 + now_ts = datetime.datetime.now(datetime.timezone.utc).timestamp() await user_config.heat.set(heat) - + await user_config.heat_last_set.set(now_ts) material_heat += 1 await user_config.material_heat.set(material_heat) @@ -191,13 +243,17 @@ async def resolve_heist( adjusted_chance = min(material_drop_chance + (material_heat * 0.04), 0.9) if random.random() < adjusted_chance: await user_config.material_heat.set(0) - materials = [k for k, v in ITEMS.items() if v[1].get("type") == "material"] + materials = data.get("material_tiers") or [ + k for k, v in ITEMS.items() if v[1].get("type") == "material" + ] if materials: dropped = random.choice(materials) qty = random.randint(1, 3) if success else random.randint(1, 2) inventory[dropped] = inventory.get(dropped, 0) + qty await user_config.inventory.set(inventory) - msg_parts.append(f"Found {qty}× {fmt(dropped)} material!") + msg_parts.append( + f"{random.choice(_FLAVOUR_MATERIAL)}\n-# Found {qty}× {fmt(dropped)}" + ) caught = False base_police_chance = data["police_chance"] @@ -213,8 +269,6 @@ async def resolve_heist( {"end_time": end_time.timestamp(), "bail_amount": bail_amount} ) - msg_parts.append("But you got **caught** by the police!") - if success: if loot_item: current = inventory.get(loot_item, 0) @@ -222,10 +276,10 @@ async def resolve_heist( inventory[loot_item] = current - 1 if inventory[loot_item] <= 0: inventory.pop(loot_item, None) - msg_parts.append(f"Lost the stolen {fmt(loot_item)}.") + msg_parts.append(f"Police confiscated the **{fmt(loot_item)}**.") elif reward > 0: await bank.withdraw_credits(member, reward) - msg_parts.append(f"Lost the gained {reward:,} {currency_name}.") + msg_parts.append(f"Police seized **{reward:,} {currency_name}** from you.") else: loot_items = [ k for k in inventory if ITEMS.get(k, (None, {}))[1].get("type") == "loot" @@ -237,7 +291,7 @@ async def resolve_heist( inventory[item] = current - 1 if inventory[item] <= 0: inventory.pop(item, None) - msg_parts.append(f"Police confiscated your {fmt(item)}.") + msg_parts.append(f"Police confiscated your **{fmt(item)}**.") await user_config.inventory.set(inventory) @@ -245,36 +299,232 @@ async def resolve_heist( total_bail = bail_amount + tax end_ts = int(end_time.timestamp()) msg_parts.append( - f"In jail until (). " + f"**In jail** until ().\n" f"Bail: {bail_amount:,} + {tax:,} tax = **{total_bail:,}** {currency_name}." ) - msg = "\n".join(msg_parts).strip() - color = 0xFF0000 if caught else 0xA020F0 - - embed = discord.Embed( - title="Heist Result", - description=msg, - color=color, + if caught: + colour = 0xFF0000 + elif success: + colour = 0xA020F0 + else: + colour = 0xFF6600 + + heist_emoji = data.get("emoji", "🎭") + result_view = _build_result_view( + heist_type=heist_type, + heist_emoji=heist_emoji, + member_mention=member.mention, + success=success, + caught=caught, + msg_parts=msg_parts, + colour=colour, ) try: - await channel.send(f"{member.mention}", embed=embed) + await channel.send(view=result_view) except discord.Forbidden: log.warning("Cannot send to %s", channel.id) if fallback_channel_id: fb = cog.bot.get_channel(fallback_channel_id) if fb: try: - await fb.send(f"{member.mention} {msg}") + await fb.send(view=result_view) except Exception: log.warning("Fallback %s also failed", fallback_channel_id) except Exception as e: log.error("resolve_heist error for %s - %s: %s", user.id, heist_type, e, exc_info=True) finally: - # Only clear active_heist if member was successfully resolved if member is not None: await cog.config.user(member).active_heist.clear() else: await cog.config.user_from_id(user.id).active_heist.clear() + + +async def resolve_crew_heist( + cog, + members: list, + heist_type: str, + channel, +): + """Resolve a crew heist for all members and send a combined result.""" + try: + data = await cog.get_heist_settings(heist_type) + currency_name = await bank.get_currency_name(channel.guild) + base_success = random.randint(data["min_success"], data["max_success"]) + success_chance = base_success / 100 + success = random.random() < success_chance + + total_reward = 0 + if success: + total_reward = random.randint(data["min_reward"], data["max_reward"]) + total_loss = random.randint(data["min_loss"], data["max_loss"]) if not success else 0 + + per_member_reward = total_reward // len(members) if success else 0 + per_member_loss = total_loss // len(members) if not success else 0 + + member_lines = [] + caught_members = [] + any_caught = False + + for member in members: + user_config = cog.config.user(member) + inventory = await user_config.inventory() + equipped = await user_config.equipped() + heat = await cog._get_effective_heat(member) + debt = await user_config.debt() + active = await user_config.active_heist() + tax_agreed = active.get("tax_agreed", False) if active else False + tool_boost = 0.0 + used_tool = None + equipped_tool = equipped["tool"] + if ( + equipped_tool + and inventory.get(equipped_tool, 0) > 0 + and ITEMS.get(equipped_tool, (None, {}))[1].get("for_heist") == heist_type + ): + tool_data = ITEMS[equipped_tool][1] + tool_boost = tool_data.get("boost", 0.0) + used_tool = equipped_tool + inventory[used_tool] = max(0, inventory.get(used_tool, 0) - 1) + if inventory[used_tool] == 0: + inventory.pop(used_tool, None) + await user_config.inventory.set(inventory) + + lines = [f"**{member.display_name}**"] + + if success: + actual_reward = per_member_reward + if tool_boost > 0: + actual_reward = int(actual_reward * (1 + tool_boost)) + try: + await bank.deposit_credits(member, actual_reward) + lines.append(f"+{actual_reward:,} {currency_name}") + except errors.BalanceTooHigh: + lines.append("Balance already at maximum.") + else: + loss = per_member_loss + reduction = 0.0 + equipped_shield = equipped["shield"] + if equipped_shield and inventory.get(equipped_shield, 0) > 0: + shield_data = ITEMS.get(equipped_shield, (None, {}))[1] + reduction = shield_data.get("reduction", 0.0) + inventory[equipped_shield] = max(0, inventory.get(equipped_shield, 0) - 1) + if inventory[equipped_shield] == 0: + inventory.pop(equipped_shield, None) + await user_config.inventory.set(inventory) + loss = int(loss * (1 - reduction)) + balance = await bank.get_balance(member) + if balance >= loss: + await bank.withdraw_credits(member, loss) + lines.append(f"-{loss:,} {currency_name}") + else: + debt_add = loss + if tax_agreed: + tax = int(loss * 0.2) + debt_add += tax + await user_config.debt.set(debt + debt_add) + lines.append(f"Debt +{debt_add:,} {currency_name}") + + heat += 1 + now_ts = datetime.datetime.now(datetime.timezone.utc).timestamp() + await user_config.heat.set(heat) + await user_config.heat_last_set.set(now_ts) + + base_police_chance = data["police_chance"] + adjusted_police = min(base_police_chance + (heat * 0.02), 0.9) + member_caught = random.random() < adjusted_police + + if member_caught: + any_caught = True + caught_members.append(member) + await user_config.heat.set(0) + bail_amount = int(data["max_loss"] * random.uniform(0.5, 1.0)) + jail_duration = data["jail_time"] + end_time = datetime.datetime.now(datetime.timezone.utc) + jail_duration + await user_config.jail.set( + { + "end_time": end_time.timestamp(), + "bail_amount": bail_amount, + } + ) + end_ts = int(end_time.timestamp()) + tax = int(bail_amount * 0.15) + lines.append(f"🚨 Caught - jail until ") + else: + lines.append("✅ Got away clean") + + material_heat = await user_config.material_heat() + material_heat += 1 + await user_config.material_heat.set(material_heat) + material_drop_chance = data.get("material_drop_chance", 0.0) + adjusted_chance = min(material_drop_chance + (material_heat * 0.04), 0.9) + if random.random() < adjusted_chance: + await user_config.material_heat.set(0) + materials = data.get("material_tiers") or [ + k for k, v in ITEMS.items() if v[1].get("type") == "material" + ] + if materials: + dropped = random.choice(materials) + qty = random.randint(1, 2) + inventory[dropped] = inventory.get(dropped, 0) + qty + await user_config.inventory.set(inventory) + lines.append(f"-# Found {qty}× {fmt(dropped)}") + + await user_config.active_heist.clear() + member_lines.append("\n".join(lines)) + + if any_caught: + narrative = random.choice(_CREW_FLAVOUR_CAUGHT) + colour = 0xFF0000 + elif success: + narrative = random.choice(_CREW_FLAVOUR_SUCCESS) + colour = 0xA020F0 + else: + narrative = random.choice(_CREW_FLAVOUR_FAIL) + colour = 0xFF6600 + + status = "✅ Success" if success else "❌ Failed" + if any_caught: + status += " - 🚨 Some Caught" + + mentions = " ".join(m.mention for m in members) + header = f"## 👥 Crew Robbery - {status}\n{mentions}\n*{narrative}*" + + components: list = [discord.ui.TextDisplay(header)] + + if success: + components.append(discord.ui.Separator()) + components.append( + discord.ui.TextDisplay( + f"**Total haul:** {total_reward:,} {currency_name} split {len(members)} ways\n" + f"**Per member:** ~{per_member_reward:,} {currency_name}" + ) + ) + else: + components.append(discord.ui.Separator()) + components.append( + discord.ui.TextDisplay( + f"**Total loss:** {total_loss:,} {currency_name} split {len(members)} ways" + ) + ) + + components.append(discord.ui.Separator()) + components.append( + discord.ui.TextDisplay("**Individual results:**\n" + "\n\n".join(member_lines)) + ) + + view = discord.ui.LayoutView(timeout=None) + view.add_item(discord.ui.Container(*components, accent_colour=discord.Colour(colour))) + + try: + await channel.send(view=view) + except discord.Forbidden: + log.warning("Cannot send crew result to %s", channel.id) + + except Exception as e: + log.error("resolve_crew_heist error: %s", e, exc_info=True) + for member in members: + with contextlib.suppress(Exception): + await cog.config.user(member).active_heist.clear() diff --git a/heist/heist.py b/heist/heist.py index d20ae98a..f2214783 100644 --- a/heist/heist.py +++ b/heist/heist.py @@ -24,23 +24,29 @@ import asyncio import datetime -import random from typing import Final import discord from red_commons.logging import getLogger from redbot.core import Config, bank, commands -from redbot.core.utils.views import ConfirmView +from .commands.owner_commands import OwnerCommands +from .commands.user_commands import UserCommands from .handlers import resolve_heist, schedule_resolve -from .utils import HEISTS, ITEMS, RECIPES -from .views import HeistConfigView, HeistView, ItemPriceConfigView, ShopView +from .utils import HEISTS, ITEMS +from .views import ConfirmLayoutView log = getLogger("red.cogs.heist") -class Heist(commands.Cog): +def _simple_view(text: str) -> discord.ui.LayoutView: + v = discord.ui.LayoutView(timeout=None) + v.add_item(discord.ui.Container(discord.ui.TextDisplay(text))) + return v + + +class Heist(UserCommands, OwnerCommands, commands.Cog): """A game where players commit heists to steal valuable items or currency, using tools to boost success and shields to reduce losses, with a risk of getting caught by police!""" __version__: Final[str] = "1.0.0" @@ -61,6 +67,7 @@ def __init__(self, bot): "equipped": {"shield": None, "tool": None, "consumable": None}, "heat": 0, "material_heat": 0, + "heat_last_set": None, } default_global = { "heist_settings": { @@ -86,9 +93,7 @@ def __init__(self, bot): self.pending_tasks = {} def format_help_for_context(self, ctx: commands.Context) -> str: - """ - Thanks Sinbad! - """ + """Thanks Sinbad!""" base = super().format_help_for_context(ctx) return f"{base}\n\nAuthor: {self.__author__}\nCog Version: {self.__version__}\nDocs: {self.__docs__}" @@ -157,46 +162,23 @@ async def _is_in_jail(self, user: discord.Member) -> bool: return False return True - async def cog_load(self): - all_users = await self.config.all_users() - for user_id, data in all_users.items(): - active = data.get("active_heist") - if active: - now = datetime.datetime.now(datetime.timezone.utc).timestamp() - if now >= active["end_time"]: - user = self.bot.get_user(user_id) - if active.get("channel_id"): - channel = self.bot.get_channel(active["channel_id"]) - if channel: - if user: - await resolve_heist(self, user, active["type"], channel) - else: - # User not cached yet; clear the stale active heist - await self.config.user_from_id(user_id).active_heist.clear() - log.warning( - "Could not resolve heist for uncached user %s on cog load", - user_id, - ) - else: - remaining = active["end_time"] - now - if active.get("channel_id"): - channel = self.bot.get_channel(active["channel_id"]) - if channel: - if user_id in self.pending_tasks: - log.warning( - "Duplicate task for user %s on cog_load, cancelling old", - user_id, - ) - self.pending_tasks[user_id].cancel() - task = asyncio.create_task( - schedule_resolve(self, user_id, remaining, active["type"], channel) - ) - self.pending_tasks[user_id] = task - - async def cog_unload(self): - for task in list(self.pending_tasks.values()): - task.cancel() - self.pending_tasks.clear() + async def _get_effective_heat(self, user: discord.Member) -> int: + """Return heat decayed by 1 per 2 hours idle, and persist the result.""" + heat = await self.config.user(user).heat() + if heat <= 0: + return 0 + last_set = await self.config.user(user).heat_last_set() + if last_set is None: + return heat + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + hours_idle = (now - last_set) / 3600 + decay = int(hours_idle / 2) + if decay > 0: + new_heat = max(0, heat - decay) + await self.config.user(user).heat.set(new_heat) + await self.config.user(user).heat_last_set.set(now) + return new_heat + return heat async def check_debt(self, ctx: commands.Context) -> bool: """Check if user has debt and prompt for payment.""" @@ -205,25 +187,30 @@ async def check_debt(self, ctx: commands.Context) -> bool: return True balance = await bank.get_balance(ctx.author) currency_name = await bank.get_currency_name(ctx.guild) - view = ConfirmView(ctx.author, timeout=60, disable_buttons=True) - prompt_msg = await ctx.send( - f"You owe {debt:,} {currency_name} in debt. Your balance is {balance:,} {currency_name}. Do you want to pay now?", - view=view, + body = ( + f"## 💸 Outstanding Debt\n" + f"You owe **{debt:,} {currency_name}** in debt.\n" + f"Your current balance is **{balance:,} {currency_name}**.\n\n" + f"Pay **{min(balance, debt):,} {currency_name}** now?" ) + view = ConfirmLayoutView(ctx.author, body, timeout=60) + view.message = await ctx.send(view=view) await view.wait() - if view.result is None: - await prompt_msg.edit(content="Debt payment timed out.", view=None) + if view.confirmed is None: + await view.message.edit(view=_simple_view("Debt payment timed out.")) return False - if not view.result: - await prompt_msg.edit(content="Debt payment declined.", view=None) + if not view.confirmed: + await view.message.edit(view=_simple_view("Debt payment declined.")) return False pay_amount = min(balance, debt) await bank.withdraw_credits(ctx.author, pay_amount) remaining_debt = debt - pay_amount await self.config.user(ctx.author).debt.set(remaining_debt) - await prompt_msg.edit( - content=f"Paid {pay_amount:,} {currency_name} towards your debt. ({remaining_debt:,} {currency_name} debt remains.)", - view=None, + await view.message.edit( + view=_simple_view( + f"Paid **{pay_amount:,}** {currency_name} towards your debt. " + f"(**{remaining_debt:,}** {currency_name} remaining.)" + ) ) return remaining_debt == 0 @@ -241,33 +228,42 @@ async def check_jail(self, ctx: commands.Context, jailed_user: discord.Member) - tax = int(bail_amount * 0.15) total_bail = bail_amount + tax end_timestamp = int(jail["end_time"]) - prompt_msg_content = ( - f"{'You are' if jailed_user == ctx.author else f'{jailed_user.display_name} is'} in jail until (). " - f"Your balance is {balance:,} {currency_name}. " - f"Bail out for {bail_amount:,} + {tax:,} (15%) tax = {total_bail:,} {currency_name}?" + is_self = jailed_user == ctx.author + target = "You are" if is_self else f"**{jailed_user.display_name}** is" + body = ( + f"## 🚨 {'Behind Bars' if is_self else 'Bail Request'}\n" + f"{target} in jail until ().\n\n" + f"**Bail amount:** {bail_amount:,} + {tax:,} (15% tax) = **{total_bail:,}** {currency_name}\n" + f"**Your balance:** {balance:,} {currency_name}\n\n" + f"Pay bail now?" ) - view = ConfirmView(ctx.author, timeout=60, disable_buttons=True) - prompt_msg = await ctx.send(prompt_msg_content, view=view) + view = ConfirmLayoutView(ctx.author, body, timeout=60) + view.message = await ctx.send(view=view) await view.wait() - if view.result is None: - await prompt_msg.edit(content="Bailout timed out.", view=None) + if view.confirmed is None: + await view.message.edit(view=_simple_view("Bailout timed out.")) return False - if not view.result: - await prompt_msg.edit(content="Bailout declined.", view=None) + if not view.confirmed: + await view.message.edit(view=_simple_view("Bailout declined.")) return False if balance < total_bail: - await prompt_msg.edit( - content=f"You need {total_bail:,} {currency_name} to bail out {'yourself' if jailed_user == ctx.author else jailed_user.display_name}, but you only have {balance:,} {currency_name}.", - view=None, + await view.message.edit( + view=_simple_view( + f"You need **{total_bail:,}** {currency_name} to bail out " + f"{'yourself' if is_self else jailed_user.display_name}, " + f"but you only have **{balance:,}**." + ) ) return False await bank.withdraw_credits(ctx.author, total_bail) await self.config.user(jailed_user).jail.clear() await self.config.user(jailed_user).heat.set(0) await self.config.user(jailed_user).material_heat.set(0) - await prompt_msg.edit( - content=f"{ctx.author.mention} paid {total_bail:,} {currency_name} to bail out {'yourself' if jailed_user == ctx.author else jailed_user.display_name}. {'You are' if jailed_user == ctx.author else 'They are'} free!", - view=None, + await view.message.edit( + view=_simple_view( + f"{ctx.author.mention} paid **{total_bail:,}** {currency_name} — " + f"{'you are' if is_self else f'{jailed_user.display_name} is'} free!" + ) ) return True @@ -298,6 +294,7 @@ async def get_heist_settings(self, heist_type: str) -> dict: "max_loss": defaults.get("max_loss", 0), "police_chance": custom.get("police_chance", defaults.get("police_chance", 0.0)), "material_drop_chance": defaults.get("material_drop_chance", 0.0), + "material_tiers": defaults.get("material_tiers"), "jail_time": datetime.timedelta( seconds=custom.get( "jail_time", @@ -312,665 +309,42 @@ async def get_item_cost(self, item_name: str) -> int: custom = await self.config.item_settings.get_raw(item_name, default={}) return int(custom.get("cost", default_cost)) - @commands.hybrid_group() - @commands.guild_only() - async def heist(self, ctx): - """Heist game.""" - - @heist.group(name="equip") - async def heist_equip(self, ctx: commands.Context): - """Equip items for heists.""" - - @heist_equip.command(name="shield") - async def equip_shield(self, ctx: commands.Context, shield_type: str): - """Equip a shield from your inventory.""" - if not await self.check_jail(ctx, ctx.author): - return - if await self._has_active_heist(ctx.author, ctx.channel.id): - return await ctx.send( - "You have an active heist ongoing. You can't equip during a heist" - ) - shield_type = shield_type.lower().replace(" ", "_") - if shield_type not in ITEMS or ITEMS[shield_type][1]["type"] != "shield": - return await ctx.send("Invalid shield type.") - inventory = await self.config.user(ctx.author).inventory() - if inventory.get(shield_type, 0) <= 0: - return await ctx.send( - f"You don't have a {shield_type.replace('_', ' ').title()} in your inventory." - ) - equipped = await self.config.user(ctx.author).equipped() - equipped["shield"] = shield_type - await self.config.user(ctx.author).equipped.set(equipped) - await ctx.send(f"Equipped {shield_type.replace('_', ' ').title()} as your shield.") - - @heist_equip.command(name="tool") - async def equip_tool(self, ctx: commands.Context, tool_type: str): - """Equip a tool from your inventory.""" - if not await self.check_jail(ctx, ctx.author): - return - if await self._has_active_heist(ctx.author, ctx.channel.id): - return await ctx.send( - "You have an active heist ongoing. You can't equip during a heist" - ) - tool_type = tool_type.lower().replace(" ", "_") - if tool_type not in ITEMS or ITEMS[tool_type][1]["type"] != "tool": - return await ctx.send("Invalid tool type.") - inventory = await self.config.user(ctx.author).inventory() - if inventory.get(tool_type, 0) <= 0: - return await ctx.send( - f"You don't have a {tool_type.replace('_', ' ').title()} in your inventory." - ) - equipped = await self.config.user(ctx.author).equipped() - equipped["tool"] = tool_type - await self.config.user(ctx.author).equipped.set(equipped) - await ctx.send(f"Equipped {tool_type.replace('_', ' ').title()} as your tool.") - - @heist_equip.command(name="consumable") - async def equip_consumable(self, ctx: commands.Context, consumable_type: str): - """Equip a consumable from your inventory.""" - if not await self.check_jail(ctx, ctx.author): - return - if await self._has_active_heist(ctx.author, ctx.channel.id): - return await ctx.send( - "You have an active heist ongoing. You can't equip during a heist" - ) - consumable_type = consumable_type.lower().replace(" ", "_") - if consumable_type not in ITEMS or ITEMS[consumable_type][1]["type"] != "consumable": - return await ctx.send("Invalid consumable type.") - inventory = await self.config.user(ctx.author).inventory() - if inventory.get(consumable_type, 0) <= 0: - return await ctx.send( - f"You don't have a {consumable_type.replace('_', ' ').title()} in your inventory." - ) - equipped = await self.config.user(ctx.author).equipped() - equipped["consumable"] = consumable_type - await self.config.user(ctx.author).equipped.set(equipped) - await ctx.send(f"Equipped {consumable_type.replace('_', ' ').title()} as your consumable.") - - @heist_equip.command(name="unequip") - async def equip_unequip_item(self, ctx: commands.Context, item_type: str): - """Unequip a specific item type.""" - if not await self.check_jail(ctx, ctx.author): - return - if await self._has_active_heist(ctx.author, ctx.channel.id): - return await ctx.send( - "You have an active heist ongoing. You can't equip during a heist" - ) - item_type = item_type.lower() - if item_type not in ["shield", "tool", "consumable"]: - return await ctx.send("Invalid item type. Use 'shield', 'tool', or 'consumable'.") - equipped = await self.config.user(ctx.author).equipped() - if equipped[item_type] is None: - return await ctx.send(f"No {item_type} equipped.") - equipped[item_type] = None - await self.config.user(ctx.author).equipped.set(equipped) - await ctx.send(f"Unequipped your {item_type}.") - - @heist.command(name="shop", aliases=["shopping"]) - @commands.bot_has_permissions(embed_links=True) - async def buy_item(self, ctx: commands.Context): - """Buy items like shields or tools to aid in heists.""" - if not await self.check_jail(ctx, ctx.author): - return - if not await self.check_debt(ctx): - return - if await self._has_active_heist(ctx.author, ctx.channel.id): - return await ctx.send("You cannot go shopping while on a heist.") - - view = ShopView(self, ctx) - currency_name = await bank.get_currency_name(ctx.guild) - embed = discord.Embed( - title="Available Items", - description="Select an item to purchase.", - color=await ctx.embed_color(), - ) - shop_items = [ - (name, (emoji, data)) - for name, (emoji, data) in ITEMS.items() - if data["type"] in ["shield", "tool", "consumable"] and "cost" in data - ] - for name, (emoji, data) in shop_items[:25]: - cost = await self.get_item_cost(name) - effect = "" - if data["type"] == "shield": - effect = f"Reduces loss by {data['reduction'] * 100:.1f}% (single use)" - elif data["type"] == "tool": - effect = f"Boosts success by {data['boost'] * 100:.0f}% for {data['for_heist'].replace('_', ' ').title()} (single use)" - elif data["type"] == "consumable": - effect = f"Reduces risk by {data['risk_reduction'] * 100:.0f}% (single use)" - embed.add_field( - name=f"{emoji} {name.replace('_', ' ').title()}", - value=f"**Cost**: {cost:,} {currency_name}\n**Effect**: {effect}", - inline=True, - ) - message = await ctx.send(embed=embed, view=view) - view.message = message - - @heist.command(name="start") - @commands.bot_has_permissions(embed_links=True) - async def do_heist(self, ctx: commands.Context): - """Attempt a heist to steal items.""" - if not await self.check_debt(ctx): - return - if not await self.check_jail(ctx, ctx.author): - return - if await self._has_active_heist(ctx.author, ctx.channel.id): - return await ctx.send("You have an active heist ongoing. Wait for it to finish.") - - equipped = await self.config.user(ctx.author).equipped() - inventory = await self.config.user(ctx.author).inventory() - warnings = [] - for item_type in ["shield", "tool", "consumable"]: - equipped_item = equipped[item_type] - if equipped_item and inventory.get(equipped_item, 0) <= 0: - warnings.append( - f"Your equipped {item_type} ({equipped_item.replace('_', ' ').title()}) is out of stock and will not be used." - ) - equipped[item_type] = None # Unequip - await self.config.user(ctx.author).equipped.set(equipped) - if warnings: - await ctx.send("\n".join(warnings)) - - # Batch-read all heist settings in one config call instead of N calls - _raw_settings = await self.config.heist_settings() - heist_settings = {} - for name in HEISTS: - defaults = HEISTS[name] - custom = _raw_settings.get(name, {}) - heist_settings[name] = { - "emoji": defaults.get("emoji", "❓"), - "risk": custom.get("risk", defaults.get("risk", 0.0)), - "min_reward": int(custom.get("min_reward", defaults.get("min_reward", 0))), - "max_reward": int(custom.get("max_reward", defaults.get("max_reward", 0))), - "cooldown": datetime.timedelta( - seconds=custom.get("cooldown", defaults["cooldown"].total_seconds()) - ), - "min_success": int(custom.get("min_success", defaults.get("min_success", 0))), - "max_success": int(custom.get("max_success", defaults.get("max_success", 100))), - "duration": datetime.timedelta( - seconds=custom.get("duration", defaults["duration"].total_seconds()) - ), - "min_loss": defaults.get("min_loss", 0), - "max_loss": defaults.get("max_loss", 0), - "police_chance": custom.get("police_chance", defaults.get("police_chance", 0.0)), - "material_drop_chance": defaults.get("material_drop_chance", 0.0), - "jail_time": datetime.timedelta( - seconds=custom.get("jail_time", defaults["jail_time"].total_seconds()) - ), - } - - view = HeistView(self, ctx, heist_settings) - currency_name = await bank.get_currency_name(ctx.guild) - embed = discord.Embed( - title="Select a Heist", - description="Choose a heist type. Higher levels have better rewards but higher risk and lower success rates.", - color=await ctx.embed_color(), - ) - for name, data in heist_settings.items(): - loot_item = name if name in ITEMS and ITEMS[name][1]["type"] == "loot" else None - if loot_item: - min_reward = ITEMS[loot_item][1]["min_sell"] - max_reward = ITEMS[loot_item][1]["max_sell"] - else: - min_reward = data["min_reward"] - max_reward = data["max_reward"] - cooldown_minutes = data["cooldown"].total_seconds() / 60 - cooldown_display = ( - f"{int(cooldown_minutes)}m" - if cooldown_minutes < 60 - else f"{int(cooldown_minutes // 60)}h" - ) - embed.add_field( - name=f"{data['emoji']} {name.replace('_', ' ').title()}", - value=( - f"**Reward**: {min_reward:,}-{max_reward:,} {currency_name}\n" - f"**Risk**: {data['risk'] * 100:.0f}%\n" - f"**Cooldown**: {cooldown_display}\n" - f"**Success**: {data['min_success']}-{data['max_success']}%\n" - f"**Duration**: {int(data['duration'].total_seconds() // 60)} min" - ), - inline=True, - ) - message = await ctx.send(embed=embed, view=view) - view.message = message - - @heist.command(name="bailout") - async def bailout(self, ctx: commands.Context, member: discord.Member = None): - """Pay bail to get yourself or another user out of jail.""" - jailed_user = member if member else ctx.author - if not await self._is_in_jail(jailed_user): - return await ctx.send(f"{jailed_user.display_name} is not in jail!") - await self.check_jail(ctx, jailed_user) - - @heist.command(name="inventory", aliases=["inv"]) - @commands.bot_has_permissions(embed_links=True) - async def check_inventory(self, ctx: commands.Context): - """Check your stolen items and tools.""" - if not await self.check_jail(ctx, ctx.author): - return - if await self._has_active_heist(ctx.author, ctx.channel.id): - return await ctx.send( - "You have an active heist ongoing. You can't check inventory during heist" - ) - inventory = await self.config.user(ctx.author).inventory() - debt = await self.config.user(ctx.author).debt() - currency_name = await bank.get_currency_name(ctx.guild) - if not inventory and debt <= 0: - return await ctx.send("Your inventory is empty and you have no debt.") - - embed = discord.Embed( - title=f"{ctx.author.name}'s Inventory", - description="Items you can sell or use.", - color=await ctx.embed_color(), - ) - if debt > 0: - embed.add_field(name="Debt", value=f"Owed: {debt:,} {currency_name}", inline=False) - equipped = await self.config.user(ctx.author).equipped() - for item, count in inventory.items(): - emoji, data = ITEMS.get(item, ("❓", {"type": "unknown"})) - desc = "" - is_equipped = False - if data["type"] == "tool": - desc = f"Boosts {data['for_heist'].replace('_', ' ').title()} success by {data['boost'] * 100:.0f}% (single use)" - if equipped["tool"] == item: - is_equipped = True - elif data["type"] == "shield": - desc = f"Reduces loss by {data['reduction'] * 100:.1f}% (single use)" - if equipped["shield"] == item: - is_equipped = True - elif data["type"] == "consumable": - desc = f"Reduces risk by {data['risk_reduction'] * 100:.0f}% (single use)" - if equipped["consumable"] == item: - is_equipped = True - elif data["type"] == "loot": - desc = f"Sell for {data['min_sell']:,} to {data['max_sell']:,} {currency_name}" - elif data["type"] == "material": - desc = f"Material for crafting. Sell for {data['min_sell']:,} to {data['max_sell']:,} {currency_name}" - else: - desc = "Unknown item" - if is_equipped: - desc += " (equipped)" - embed.add_field( - name=f"{emoji} {item.replace('_', ' ').title()} (x{count})", - value=desc, - inline=True, - ) - await ctx.send(embed=embed) - - @heist.command(name="sell") - async def sell_item(self, ctx: commands.Context, item: str, amount: int = 1): - """Sell a stolen item or material for currency.""" - if not await self.check_jail(ctx, ctx.author): - return - if await self._has_active_heist(ctx.author, ctx.channel.id): - return await ctx.send( - "You have an active heist ongoing. You can't sell while on heist." - ) - inventory = await self.config.user(ctx.author).inventory() - item = item.lower().replace(" ", "_") - if item not in ITEMS or ITEMS[item][1]["type"] not in ["loot", "material"]: - return await ctx.send("Invalid item type. Only loot and materials can be sold.") - if inventory.get(item, 0) < amount: - return await ctx.send( - f"You don't have enough {item.replace('_', ' ').title()} to sell." - ) - data = ITEMS[item][1] - sell_price = sum(random.randint(data["min_sell"], data["max_sell"]) for _ in range(amount)) - await bank.deposit_credits(ctx.author, sell_price) - inventory[item] -= amount - if inventory[item] <= 0: - del inventory[item] - await self.config.user(ctx.author).inventory.set(inventory) - currency_name = await bank.get_currency_name(ctx.guild) - await ctx.send( - f"Sold {amount} {item.replace('_', ' ').title()} for {sell_price:,} {currency_name}." - ) - - @heist.command(name="craft") - async def craft_item(self, ctx: commands.Context, recipe_name: str): - """ - Craft upgraded shields or tools using materials from heists. - - Craftable items are stronger than shop-bought items but require specific materials. - - **Vaild craftable items**: - - `reinforced_wooden_shield`, `enhanced_pickpocket_gloves`, `reinforced_iron_shield`, `reinforced_steel_shield`, `reinforced_titanium_shield`, `reinforced_diamond_shield`, `enhanced_crowbar`, `enhanced_glass_cutter`, `enhanced_brass_knuckles`, `enhanced_laser_jammer`, `enhanced_hacking_device`, `enhanced_store_key`, `enhanced_lockpick_kit`, `enhanced_grappling_hook`, `enhanced_bike_tool`, `enhanced_car_tool` and `enhanced_motorcycle_tool`. - """ - if not await self.check_jail(ctx, ctx.author): - return - if await self._has_active_heist(ctx.author, ctx.channel.id): - return await ctx.send( - "You have an active heist ongoing. You can't craft while on heist." - ) - recipe_name = recipe_name.lower().replace(" ", "_") - if recipe_name not in RECIPES: - return await ctx.send("Invalid recipe.") - recipe = RECIPES[recipe_name] - inventory = await self.config.user(ctx.author).inventory() - missing = [] - for mat, qty in recipe["materials"].items(): - if inventory.get(mat, 0) < qty: - missing.append( - f"{mat.replace('_', ' ').title()} (need {qty}, have {inventory.get(mat, 0)})" - ) - if missing: - return await ctx.send(f"Missing materials: {', '.join(missing)}") - for mat, qty in recipe["materials"].items(): - inventory[mat] -= qty - if inventory[mat] <= 0: - del inventory[mat] - result = recipe["result"] - inventory[result] = inventory.get(result, 0) + recipe["quantity"] - await self.config.user(ctx.author).inventory.set(inventory) - await ctx.send(f"Crafted {recipe['quantity']} {result.replace('_', ' ').title()}!") - - @heist.command(name="shield") - async def check_shield(self, ctx: commands.Context): - """Check your active shield status.""" - if not await self.check_jail(ctx, ctx.author): - return - equipped = await self.config.user(ctx.author).equipped() - equipped_shield = equipped["shield"] - if equipped_shield: - inventory = await self.config.user(ctx.author).inventory() - count = inventory.get(equipped_shield, 0) - if count > 0: - emoji, data = ITEMS[equipped_shield] - return await ctx.send( - f"Active {emoji} {equipped_shield.replace('_', ' ').title()} shield: Reduces loss by {data['reduction'] * 100:.1f}% (single use). You have {count}." - ) - await ctx.send("No active shield.") - - @heist.command(name="status") - async def heist_status(self, ctx: commands.Context): - """Check your active heist status.""" - active = await self.config.user(ctx.author).active_heist() - jail = await self.config.user(ctx.author).jail() - msg = "" - if active: - end_time = datetime.datetime.fromtimestamp( - active["end_time"], tz=datetime.timezone.utc - ) - remaining = end_time - datetime.datetime.now(datetime.timezone.utc) - msg += ( - f"Active {active['type'].replace('_', ' ').title()} heist. Ends in {remaining}.\n" - ) - if jail: - now = datetime.datetime.now(datetime.timezone.utc).timestamp() - if now < jail["end_time"]: - end_timestamp = int(jail["end_time"]) - msg += f"In jail until ()." - else: - await self.config.user(ctx.author).jail.clear() - if not msg: - msg = "No active heist or jail time." - await ctx.send(msg) - - @heist.command(name="cooldowns", aliases=["cooldown"]) - @commands.bot_has_permissions(embed_links=True) - async def check_cooldowns(self, ctx: commands.Context): - """Check cooldowns for all heists.""" - if not await self.check_jail(ctx, ctx.author): - return - cooldowns = await self.config.user(ctx.author).heist_cooldowns() - now = datetime.datetime.now(datetime.timezone.utc) - embed = discord.Embed( - title="Heist Cooldowns", - description="", - color=await ctx.embed_color(), - ) - any_on_cooldown = False - for heist_type in sorted(HEISTS.keys(), key=lambda x: x.replace("_", " ").title()): - data = await self.get_heist_settings(heist_type) - last_timestamp = cooldowns.get(heist_type) - heist_name = heist_type.replace("_", " ").title() - if last_timestamp: - last_time = datetime.datetime.fromtimestamp( - last_timestamp, tz=datetime.timezone.utc - ) - if now - last_time < data["cooldown"]: - remaining = data["cooldown"] - (now - last_time) - end_time = now + remaining - end_timestamp = int(end_time.timestamp()) - embed.description += ( - f"{data['emoji']} `{heist_name + ':':<18}`: \n" - ) - any_on_cooldown = True - else: - embed.description += f"{data['emoji']} `{heist_name + ':':<18}`: Ready\n" - else: - embed.description += f"{data['emoji']} `{heist_name + ':':<18}`: Ready\n" - if not any_on_cooldown: - embed.description = "All heists are ready!" - await ctx.send(embed=embed) - - @commands.group(with_app_command=False) - @commands.is_owner() - async def heistset(self, ctx): - """Manage global heist settings.""" - - @heistset.command(name="set") - async def heistset_set(self, ctx: commands.Context): - """ - Modify a parameter for a specific heist type using a modal. - - You can edit multiple prices until the interaction times out. - - **Arguments** - - heist_type: Valid options: - - `pocket_steal`, `atm_smash`, `store_robbery`, `jewelry_store`, `fight_club`, `art_gallery`, `casino_vault`, `museum_relic`, `luxury_yacht`, `street_bike`, `street_motorcycle`, `street_car`, `corporate`, `bank`, `elite`. - - param: The parameter to change. Valid options: - - `risk`: Failure probability (0–100, percentage). - - `min_reward`: Minimum reward amount (credits, non-negative). - - `max_reward`: Maximum reward amount (credits, non-negative, must be above min_reward). - - `cooldown`: Time before the heist can be attempted again (seconds, minimum 60). - - `min_success`: Minimum success chance (0–100, percentage). - - `max_success`: Maximum success chance (0–100, percentage, must be above min_success). - - `duration`: Time to complete the heist (seconds, minimum 30). - - `police_chance`: Chance of getting caught (0–100, percentage). - - `jail_time`: Jail duration if caught (seconds, minimum 60). - - value: The new value for the parameter (percentage for risk/success, credits for rewards, seconds for cooldown/duration). - """ - view = HeistConfigView(self, ctx) - message = await ctx.send( - "Click the button to configure heist settings.", view=view, ephemeral=True - ) - view.message = message - - @heistset.command(name="price") - async def heistset_price(self, ctx: commands.Context): - """ - Modify the price of an item in the shop using a modal. - - You can edit multiple prices until the interaction times out. - **Vaild options**: - - `wooden_shield`, `iron_shield`, `steel_shield`, `titanium_shield`, `diamond_shield`, `full`, `bike_tool`, `car_tool`, `motorcycle_tool`, `pickpocket_gloves`, `crowbar`, `glass_cutter`, `brass_knuckles`, `laser_jammer`, `hacking_device`, `store_key`, `lockpick_kit` and `grappling_hook` - """ - view = ItemPriceConfigView(self, ctx) - message = await ctx.send( - "Click the button to configure item prices.", view=view, ephemeral=True - ) - view.message = message - - @heistset.command(name="reset") - async def heistset_reset(self, ctx: commands.Context, heist_type: str | None = None): - """Reset heist settings to default values. - - If no heist_type is provided, resets all heists. - """ - async with self.config.heist_settings() as settings: - if heist_type: - heist_type = heist_type.lower().replace(" ", "_") - if heist_type not in HEISTS: - return await ctx.send(f"Invalid heist type: {heist_type}") - settings[heist_type] = { - "risk": HEISTS[heist_type]["risk"], - "min_reward": HEISTS[heist_type]["min_reward"], - "max_reward": HEISTS[heist_type]["max_reward"], - "cooldown": HEISTS[heist_type]["cooldown"].total_seconds(), - "min_success": HEISTS[heist_type]["min_success"], - "max_success": HEISTS[heist_type]["max_success"], - "duration": HEISTS[heist_type]["duration"].total_seconds(), - "police_chance": HEISTS[heist_type]["police_chance"], - "jail_time": HEISTS[heist_type]["jail_time"].total_seconds(), - } - await ctx.send( - f"Reset settings for {heist_type.replace('_', ' ').title()} to defaults." - ) - else: - settings.clear() - settings.update( - { - name: { - "risk": data["risk"], - "min_reward": data["min_reward"], - "max_reward": data["max_reward"], - "cooldown": data["cooldown"].total_seconds(), - "min_success": data["min_success"], - "max_success": data["max_success"], - "duration": data["duration"].total_seconds(), - "police_chance": data["police_chance"], - "jail_time": data["jail_time"].total_seconds(), - } - for name, data in HEISTS.items() - } - ) - await ctx.send("Reset all heist settings to defaults.") - - @heistset.command(name="resetprice") - async def heistset_resetprice(self, ctx: commands.Context, item_name: str | None = None): - """Reset item prices to default values. - - If no item_name is provided, resets all item prices. - """ - async with self.config.item_settings() as settings: - if item_name: - item_name = item_name.lower().replace(" ", "_") - if item_name not in ITEMS or "cost" not in ITEMS[item_name][1]: - return await ctx.send(f"Invalid item: {item_name}") - settings[item_name] = {"cost": ITEMS[item_name][1]["cost"]} - await ctx.send( - f"Reset price for {item_name.replace('_', ' ').title()} to default." - ) - else: - settings.clear() - settings.update( - { - name: {"cost": data["cost"]} - for name, (_, data) in ITEMS.items() - if "cost" in data - } - ) - await ctx.send("Reset all item prices to defaults.") - - @heistset.command(name="show") - @commands.bot_has_permissions(embed_links=True) - async def heistset_show(self, ctx: commands.Context, heist_type: str | None = None): - """Show current settings for a heist or all heists. - - Parameters: - - heist_type: The heist to show (e.g., pocket_steal). Omit for all heists. - """ - heist_type = heist_type.lower().replace(" ", "_") if heist_type else None - if heist_type and heist_type not in HEISTS: - return await ctx.send(f"Invalid heist type: {heist_type}") - - embed = discord.Embed( - title="Heist Settings", - description=f"Settings for {'all heists' if not heist_type else heist_type.replace('_', ' ').title()} (custom values marked with ⭐)", - color=await ctx.embed_color(), - ) - - heists_to_show = [heist_type] if heist_type else sorted(HEISTS.keys()) - current_settings = await self.config.heist_settings() - for name in heists_to_show: - data = await self.get_heist_settings(name) - custom = current_settings.get(name, {}) - defaults = HEISTS.get(name, {}) - _timedelta_params = {"cooldown", "duration", "jail_time"} - is_custom = {} - for param in [ - "risk", - "min_reward", - "max_reward", - "cooldown", - "min_success", - "max_success", - "duration", - "police_chance", - "jail_time", - ]: - if param not in custom: - is_custom[param] = False - continue - default_val = defaults.get(param) - if param in _timedelta_params: - default_secs = ( - default_val.total_seconds() - if isinstance(default_val, datetime.timedelta) - else 0.0 - ) - is_custom[param] = custom[param] != default_secs + async def cog_load(self): + all_users = await self.config.all_users() + for user_id, data in all_users.items(): + active = data.get("active_heist") + if active: + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + if now >= active["end_time"]: + user = self.bot.get_user(user_id) + if active.get("channel_id"): + channel = self.bot.get_channel(active["channel_id"]) + if channel: + if user: + await resolve_heist(self, user, active["type"], channel) + else: + await self.config.user_from_id(user_id).active_heist.clear() + log.warning( + "Could not resolve heist for uncached user %s on cog load", + user_id, + ) else: - is_custom[param] = custom[param] != (default_val or 0) - loot_item = name if name in ITEMS and ITEMS[name][1]["type"] == "loot" else None - reward_text = ( - f"{ITEMS[loot_item][1]['min_sell']:,}-{ITEMS[loot_item][1]['max_sell']:,} credits" - if loot_item - else f"{data['min_reward']:,}-{data['max_reward']:,} credits{' ⭐' if is_custom['min_reward'] or is_custom['max_reward'] else ''}" - ) - field_value = ( - f"Reward: {reward_text}\n" - f"Risk: {data['risk'] * 100:.0f}%{' ⭐' if is_custom['risk'] else ''}\n" - f"Success: {data['min_success']}-{data['max_success']}%{' ⭐' if is_custom['min_success'] or is_custom['max_success'] else ''}\n" - f"Cooldown: {data['cooldown'].total_seconds() / 3600:.1f}h{' ⭐' if is_custom['cooldown'] else ''}\n" - f"Duration: {int(data['duration'].total_seconds() // 60)} min{' ⭐' if is_custom['duration'] else ''}\n" - f"Police Chance: {data['police_chance'] * 100:.0f}%{' ⭐' if is_custom['police_chance'] else ''}\n" - f"Jail Time: {data['jail_time'].total_seconds() / 3600:.1f}h{' ⭐' if is_custom['jail_time'] else ''}\n" - f"Loss: {data['min_loss']:,}-{data['max_loss']:,} credits" - ) - embed.add_field( - name=f"{data['emoji']} {name.replace('_', ' ').title()}", - value=field_value, - inline=True, - ) - await ctx.send(embed=embed) - - @heistset.command(name="showprices") - @commands.bot_has_permissions(embed_links=True) - async def heistset_showprices(self, ctx: commands.Context, item_name: str | None = None): - """Show current prices for an item or all shop items. - - Parameters: - - item_name: The item to show (e.g., wooden_shield). Omit for all items. - """ - item_name = item_name.lower().replace(" ", "_") if item_name else None - shop_items = {name: data for name, (_, data) in ITEMS.items() if "cost" in data} - if item_name and item_name not in shop_items: - return await ctx.send(f"Invalid item: {item_name}") - - embed = discord.Embed( - title="Shop Item Prices", - description=f"Prices for {'all items' if not item_name else item_name.replace('_', ' ').title()} (custom values marked with ⭐)", - color=await ctx.embed_color(), - ) - embed.set_footer( - text="Use [p]heistset price to modify values or [p]heistset resetprice to revert to defaults." - ) + remaining = active["end_time"] - now + if active.get("channel_id"): + channel = self.bot.get_channel(active["channel_id"]) + if channel: + if user_id in self.pending_tasks: + log.warning( + "Duplicate task for user %s on cog_load, cancelling old", + user_id, + ) + self.pending_tasks[user_id].cancel() + task = asyncio.create_task( + schedule_resolve(self, user_id, remaining, active["type"], channel) + ) + self.pending_tasks[user_id] = task - items_to_show = [item_name] if item_name else sorted(shop_items.keys()) - current_settings = await self.config.item_settings() - for name in items_to_show: - data = shop_items[name] - custom = current_settings.get(name, {}) - default_cost = data["cost"] - is_custom = "cost" in custom and custom["cost"] != default_cost - cost = custom.get("cost", default_cost) - emoji = ITEMS[name][0] - field_value = f"Cost: {cost:,}{' ⭐' if is_custom else ''}" - embed.add_field( - name=f"{emoji} {name.replace('_', ' ').title()}", - value=field_value, - inline=True, - ) - await ctx.send(embed=embed) + async def cog_unload(self): + for task in list(self.pending_tasks.values()): + task.cancel() + self.pending_tasks.clear() diff --git a/heist/meta.py b/heist/meta.py new file mode 100644 index 00000000..d8c7b162 --- /dev/null +++ b/heist/meta.py @@ -0,0 +1,78 @@ +_PARAM_META = { + "risk": ("Risk (%)", "0–100"), + "police_chance": ("Police Chance (%)", "0–100"), + "min_success": ("Min Success (%)", "0–100"), + "max_success": ("Max Success (%)", "0–100"), + "min_reward": ("Min Reward", "credits"), + "max_reward": ("Max Reward", "credits"), + "cooldown": ("Cooldown", "seconds"), + "duration": ("Duration", "seconds"), + "jail_time": ("Jail Time", "seconds"), +} +_FLAVOUR_SUCCESS = [ + "You moved like a ghost - in and out before anyone noticed.", + "Textbook execution. The crew would be proud.", + "Clean hands, full pockets. Another day, another score.", + "No alarms. No witnesses. Just profit.", + "Like taking candy from a baby - if the baby had a vault.", + "You vanished into the night with exactly what you came for.", + "The plan worked perfectly. It never does, but this time it did.", +] + +_FLAVOUR_FAIL = [ + "Something went wrong. It always does eventually.", + "The mark was smarter than expected.", + "Bad luck. The kind you can't plan around.", + "You got out, but not with what you came for.", + "Sloppy execution. You'll do better next time.", + "The score wasn't worth the heat you brought down.", + "Close, but close only counts in horseshoes.", +] + +_FLAVOUR_CAUGHT = [ + "Red and blue lights filled the rear-view mirror.", + "Sirens. Always the sirens.", + "Someone talked. Or maybe you just got unlucky.", + "The long arm of the law finally caught up.", + "Cuffs clicked. Game over - for now.", + "You froze at the wrong moment.", +] + +_FLAVOUR_SHIELD = [ + "Your armour took the hit so you didn't have to.", + "The shield held. Barely.", + "Damage mitigated. Gear degraded.", +] + +_FLAVOUR_TOOL = [ + "Your equipment gave you the edge you needed.", + "Proper tools make proper criminals.", + "The gear performed exactly as advertised.", +] + +_FLAVOUR_MATERIAL = [ + "You pocketed some useful scraps on the way out.", + "A bonus find among the chaos.", + "Salvaged something valuable from the wreckage.", +] + +_CREW_FLAVOUR_SUCCESS = [ + "The crew moved as one. Nobody left empty-handed.", + "Four minds, one plan. It worked like clockwork.", + "In and out. The perfect score.", + "Coordination is everything - and yours was flawless.", +] + +_CREW_FLAVOUR_FAIL = [ + "Someone broke formation. The whole crew paid for it.", + "The job fell apart. Happens to the best of them.", + "Too many moving parts. Something had to go wrong.", + "The plan looked good on paper.", +] + +_CREW_FLAVOUR_CAUGHT = [ + "A whole crew in cuffs. Someone's going to write a book about this.", + "You almost made it. Almost.", + "Four sets of hands - and every one of them caught.", + "The police were waiting. Someone talked.", +] diff --git a/heist/utils.py b/heist/utils.py index f80a6073..8acd09ad 100644 --- a/heist/utils.py +++ b/heist/utils.py @@ -182,6 +182,118 @@ def fmt(s: str) -> str: "🛠️", {"type": "tool", "boost": 0.15, "for_heist": "street_motorcycle"}, ), + "coin_hook": ( + "🪝", + {"type": "tool", "cost": 150, "boost": 0.08, "for_heist": "vending_machine"}, + ), + "parking_slim_jim": ( + "🔩", + {"type": "tool", "cost": 250, "boost": 0.10, "for_heist": "parking_meter"}, + ), + "distraction_device": ( + "📦", + {"type": "tool", "cost": 700, "boost": 0.12, "for_heist": "food_truck"}, + ), + "pharmacy_mask": ( + "😷", + {"type": "tool", "cost": 2000, "boost": 0.14, "for_heist": "hospital_pharmacy"}, + ), + "trading_terminal": ( + "💻", + {"type": "tool", "cost": 4000, "boost": 0.15, "for_heist": "stock_exchange"}, + ), + "gold_drill": ( + "⛏️", + {"type": "tool", "cost": 8000, "boost": 0.16, "for_heist": "gold_reserve"}, + ), + "military_keycard": ( + "🪪", + {"type": "tool", "cost": 15000, "boost": 0.18, "for_heist": "military_depot"}, + ), + "space_suit": ( + "👨‍🚀", + {"type": "tool", "cost": 25000, "boost": 0.20, "for_heist": "space_agency"}, + ), + "corporate_badge": ( + "🏷️", + {"type": "tool", "cost": 3500, "boost": 0.14, "for_heist": "corporate"}, + ), + "bank_drill": ( + "🔨", + {"type": "tool", "cost": 12000, "boost": 0.16, "for_heist": "bank"}, + ), + "elite_kit": ( + "🧰", + {"type": "tool", "cost": 40000, "boost": 0.18, "for_heist": "elite"}, + ), + "enhanced_coin_hook": ( + "🪝", + {"type": "tool", "boost": 0.12, "for_heist": "vending_machine"}, + ), + "enhanced_parking_slim_jim": ( + "🔩", + {"type": "tool", "boost": 0.14, "for_heist": "parking_meter"}, + ), + "enhanced_distraction_device": ( + "📦", + {"type": "tool", "boost": 0.16, "for_heist": "food_truck"}, + ), + "enhanced_pharmacy_mask": ( + "😷", + {"type": "tool", "boost": 0.18, "for_heist": "hospital_pharmacy"}, + ), + "enhanced_trading_terminal": ( + "💻", + {"type": "tool", "boost": 0.20, "for_heist": "stock_exchange"}, + ), + "enhanced_gold_drill": ( + "⛏️", + {"type": "tool", "boost": 0.22, "for_heist": "gold_reserve"}, + ), + "enhanced_military_keycard": ( + "🪪", + {"type": "tool", "boost": 0.24, "for_heist": "military_depot"}, + ), + "enhanced_space_suit": ( + "👨‍🚀", + {"type": "tool", "boost": 0.26, "for_heist": "space_agency"}, + ), + "enhanced_corporate_badge": ( + "🏷️", + {"type": "tool", "boost": 0.18, "for_heist": "corporate"}, + ), + "enhanced_bank_drill": ( + "🔨", + {"type": "tool", "boost": 0.22, "for_heist": "bank"}, + ), + "enhanced_elite_kit": ( + "🧰", + {"type": "tool", "boost": 0.25, "for_heist": "elite"}, + ), + "classified_docs": ( + "📄", + {"type": "material", "min_sell": 1000, "max_sell": 3000}, + ), + "military_grade_alloy": ( + "🪨", + {"type": "material", "min_sell": 2000, "max_sell": 5000}, + ), + "gold_bar": ( + "🏅", + {"type": "loot", "min_sell": 50000, "max_sell": 200000}, + ), + "satellite_component": ( + "🛰️", + {"type": "loot", "min_sell": 100000, "max_sell": 400000}, + ), + "medical_supplies": ( + "💊", + {"type": "loot", "min_sell": 3000, "max_sell": 12000}, + ), + "stock_data": ( + "📊", + {"type": "loot", "min_sell": 10000, "max_sell": 50000}, + ), } RECIPES: dict[str, dict] = { @@ -270,6 +382,61 @@ def fmt(s: str) -> str: "result": "enhanced_motorcycle_tool", "quantity": 1, }, + "enhanced_coin_hook": { + "materials": {"scrap_metal": 2, "tech_parts": 1}, + "result": "enhanced_coin_hook", + "quantity": 1, + }, + "enhanced_parking_slim_jim": { + "materials": {"scrap_metal": 3, "tech_parts": 1}, + "result": "enhanced_parking_slim_jim", + "quantity": 1, + }, + "enhanced_distraction_device": { + "materials": {"tech_parts": 3, "scrap_metal": 2}, + "result": "enhanced_distraction_device", + "quantity": 1, + }, + "enhanced_pharmacy_mask": { + "materials": {"tech_parts": 4, "rare_alloy": 1}, + "result": "enhanced_pharmacy_mask", + "quantity": 1, + }, + "enhanced_trading_terminal": { + "materials": {"tech_parts": 5, "rare_alloy": 2}, + "result": "enhanced_trading_terminal", + "quantity": 1, + }, + "enhanced_gold_drill": { + "materials": {"rare_alloy": 4, "military_grade_alloy": 1}, + "result": "enhanced_gold_drill", + "quantity": 1, + }, + "enhanced_military_keycard": { + "materials": {"classified_docs": 2, "military_grade_alloy": 2}, + "result": "enhanced_military_keycard", + "quantity": 1, + }, + "enhanced_space_suit": { + "materials": {"classified_docs": 3, "military_grade_alloy": 3}, + "result": "enhanced_space_suit", + "quantity": 1, + }, + "enhanced_corporate_badge": { + "materials": {"tech_parts": 4, "rare_alloy": 2}, + "result": "enhanced_corporate_badge", + "quantity": 1, + }, + "enhanced_bank_drill": { + "materials": {"rare_alloy": 3, "military_grade_alloy": 2}, + "result": "enhanced_bank_drill", + "quantity": 1, + }, + "enhanced_elite_kit": { + "materials": {"rare_alloy": 5, "military_grade_alloy": 3, "classified_docs": 2}, + "result": "enhanced_elite_kit", + "quantity": 1, + }, } HEISTS: dict[str, dict] = { @@ -307,7 +474,7 @@ def fmt(s: str) -> str: "emoji": "🏬", "risk": 0.05, "min_reward": 1000, - "max_reward": 0, + "max_reward": 5000, "cooldown": datetime.timedelta(hours=1), "min_success": 30, "max_success": 60, @@ -498,4 +665,143 @@ def fmt(s: str) -> str: "jail_time": datetime.timedelta(hours=12), "material_drop_chance": 0.5, }, + "crew_robbery": { + "emoji": "👥", + "risk": 0.30, + "min_reward": 1000000, + "max_reward": 80000000, + "cooldown": datetime.timedelta(hours=8), + "min_success": 8, + "max_success": 35, + "duration": datetime.timedelta(minutes=20), + "min_loss": 80000, + "max_loss": 300000, + "police_chance": 0.45, + "jail_time": datetime.timedelta(hours=16), + "material_drop_chance": 0.6, + "crew_size": 4, + }, + "vending_machine": { + "emoji": "🥤", + "risk": 0.005, + "min_reward": 10, + "max_reward": 80, + "cooldown": datetime.timedelta(minutes=10), + "min_success": 70, + "max_success": 95, + "duration": datetime.timedelta(seconds=30), + "min_loss": 5, + "max_loss": 30, + "police_chance": 0.01, + "jail_time": datetime.timedelta(minutes=15), + "material_drop_chance": 0.05, + }, + "parking_meter": { + "emoji": "🅿️", + "risk": 0.01, + "min_reward": 50, + "max_reward": 200, + "cooldown": datetime.timedelta(minutes=20), + "min_success": 60, + "max_success": 85, + "duration": datetime.timedelta(minutes=1), + "min_loss": 20, + "max_loss": 100, + "police_chance": 0.03, + "jail_time": datetime.timedelta(minutes=30), + "material_drop_chance": 0.08, + }, + "food_truck": { + "emoji": "🚚", + "risk": 0.03, + "min_reward": 300, + "max_reward": 1200, + "cooldown": datetime.timedelta(minutes=40), + "min_success": 45, + "max_success": 70, + "duration": datetime.timedelta(minutes=3), + "min_loss": 150, + "max_loss": 600, + "police_chance": 0.06, + "jail_time": datetime.timedelta(hours=1), + "material_drop_chance": 0.15, + }, + "hospital_pharmacy": { + "emoji": "🏥", + "risk": 0.08, + "min_reward": 0, + "max_reward": 9000, + "cooldown": datetime.timedelta(hours=2), + "min_success": 20, + "max_success": 50, + "duration": datetime.timedelta(minutes=7), + "min_loss": 3000, + "max_loss": 10000, + "police_chance": 0.18, + "jail_time": datetime.timedelta(hours=4), + "material_drop_chance": 0.3, + }, + "stock_exchange": { + "emoji": "📈", + "risk": 0.12, + "min_reward": 10, + "max_reward": 4000, + "cooldown": datetime.timedelta(hours=3), + "min_success": 12, + "max_success": 45, + "duration": datetime.timedelta(minutes=10), + "min_loss": 8000, + "max_loss": 30000, + "police_chance": 0.22, + "jail_time": datetime.timedelta(hours=6), + "material_drop_chance": 0.35, + }, + "gold_reserve": { + "emoji": "🪙", + "risk": 0.22, + "min_reward": 3000000, + "max_reward": 4000000, + "cooldown": datetime.timedelta(hours=6), + "min_success": 6, + "max_success": 28, + "duration": datetime.timedelta(minutes=18), + "min_loss": 40000, + "max_loss": 180000, + "police_chance": 0.38, + "jail_time": datetime.timedelta(hours=10), + "material_drop_chance": 0.45, + "material_tiers": ["scrap_metal", "tech_parts", "rare_alloy", "military_grade_alloy"], + }, + "military_depot": { + "emoji": "🪖", + "risk": 0.28, + "min_reward": 120000, + "max_reward": 500000, + "cooldown": datetime.timedelta(hours=7), + "min_success": 8, + "max_success": 30, + "duration": datetime.timedelta(minutes=18), + "min_loss": 60000, + "max_loss": 220000, + "police_chance": 0.42, + "jail_time": datetime.timedelta(hours=14), + "material_drop_chance": 0.5, + "material_tiers": ["rare_alloy", "classified_docs", "military_grade_alloy"], + }, + "space_agency": { + "emoji": "🚀", + "risk": 0.38, + "min_reward": 100000, + "max_reward": 200000, + "cooldown": datetime.timedelta(hours=10), + "min_success": 5, + "max_success": 22, + "duration": datetime.timedelta(minutes=25), + "min_loss": 300000, + "max_loss": 700000, + "police_chance": 0.5, + "jail_time": datetime.timedelta(hours=24), + "material_drop_chance": 0.55, + "material_tiers": ["rare_alloy", "classified_docs", "military_grade_alloy"], + }, } diff --git a/heist/views.py b/heist/views.py index 6e5a752c..0bdaec71 100644 --- a/heist/views.py +++ b/heist/views.py @@ -29,147 +29,67 @@ import discord from red_commons.logging import getLogger from redbot.core import bank, commands -from redbot.core.utils.views import ConfirmView from .handlers import schedule_resolve -from .utils import HEISTS, ITEMS +from .meta import _PARAM_META +from .utils import HEISTS, ITEMS, RECIPES, fmt log = getLogger("red.cogs.heist.views") +CREW_SIZE = 4 +LOBBY_TIMEOUT = 180 # 3 minutes +HEISTS_PER_PAGE = 7 +_ALL_SHOP_PAGES = [ + ("🛡️ Shields", "shield"), + ("🔧 Tools", "tool"), +] -class ShopView(discord.ui.View): - def __init__(self, cog, ctx: commands.Context): - super().__init__(timeout=120) - self.cog = cog - self.ctx = ctx - self.add_item(ShopSelect(cog)) +def _risk_indicator(police_chance: float, risk: float) -> str: + combined = police_chance + risk + if combined < 0.15: + return "🟢 Low" + elif combined < 0.35: + return "🟡 Medium" + elif combined < 0.55: + return "🟠 High" + return "🔴 Extreme" - async def interaction_check(self, interaction: discord.Interaction) -> bool: - """Check if the user is allowed to interact.""" - if interaction.user.id not in [self.ctx.author.id, *list(self.ctx.bot.owner_ids)]: - await interaction.response.send_message( - "You are not allowed to use this interaction.", ephemeral=True - ) - return False - return True - async def on_timeout(self) -> None: - """Handle view timeout.""" - for item in self.children: - item.disabled = True - if self.message: - try: - await self.message.edit(view=self) - except discord.HTTPException as e: - log.error("Failed to update shop view on timeout: %s", e) +def _cooldown_display(td: datetime.timedelta) -> str: + secs = td.total_seconds() + if secs < 3600: + return f"{int(secs // 60)}m" + return f"{secs / 3600:.1f}h" -class ShopSelect(discord.ui.Select): - def __init__(self, cog): - self.cog = cog - options = [ - discord.SelectOption( - label=name.replace("_", " ").title(), - value=name, - description=( - f"Cost: {data['cost']:,} | " - + ( - f"Reduces loss by {data['reduction'] * 100:.1f}% (single use)" - if data["type"] == "shield" - else ( - f"Boosts {data['for_heist'].replace('_', ' ').title()} success by {data['boost'] * 100:.0f}% (single use)" - if data["type"] == "tool" - else f"Reduces risk by {data['risk_reduction'] * 100:.0f}% (single use)" - ) - ) - ), - emoji=emoji, - ) - for name, (emoji, data) in ITEMS.items() - if data["type"] in ["shield", "tool", "consumable"] - and "cost" in data # Only include purchasable items to not break select menu. - ] +class _HeistNavBtn(discord.ui.Button): + def __init__(self, direction: str, view: "HeistSelectionView", disabled: bool = False): + match direction: + case "prev": + label, emoji = "Previous", "◀️" + case "next": + label, emoji = "Next", "▶️" super().__init__( - placeholder="Select an item...", options=options[:25] - ) # Limit to 25 options due to discord's limits. - - async def callback(self, interaction: discord.Interaction): - item_type = self.values[0] - emoji, _data = ITEMS[item_type] - cost = await self.cog.get_item_cost(item_type) - balance = await bank.get_balance(interaction.user) - currency_name = await bank.get_currency_name(interaction.guild) - if balance < cost: - await interaction.response.send_message( - f"You don't have enough currency! Need {cost:,} {currency_name}, have {balance:,} {currency_name}.", - ephemeral=True, - ) - return - - # All purchasable items go into inventory (shields, tools, consumables) - await bank.withdraw_credits(interaction.user, cost) - inventory = await self.cog.config.user(interaction.user).inventory() - inventory[item_type] = inventory.get(item_type, 0) + 1 - await self.cog.config.user(interaction.user).inventory.set(inventory) - await interaction.response.send_message( - f"Purchased {emoji} {item_type.replace('_', ' ').title()} for {cost:,} {currency_name}! Added to inventory.", - ephemeral=True, + label=label, emoji=emoji, style=discord.ButtonStyle.secondary, disabled=disabled ) - for item in self.view.children: - item.disabled = True - try: - await interaction.message.edit(view=self.view) - except discord.HTTPException as e: - log.error("Failed to update shop view after purchase: %s", e) - - -class HeistView(discord.ui.View): - def __init__(self, cog, ctx: commands.Context, heist_settings: dict): - super().__init__(timeout=120) - self.cog = cog - self.ctx = ctx - self.heist_settings = heist_settings - self.message = None - options = [ - discord.SelectOption( - label=name.replace("_", " ").title(), - value=name, - emoji=data["emoji"], - description=( - f"Reward: {ITEMS[name][1]['min_sell']:,}-{ITEMS[name][1]['max_sell']:,} credits" - if name in ITEMS and ITEMS[name][1]["type"] == "loot" - else f"Reward: {data['min_reward']:,}-{data['max_reward']:,}" - ), - ) - for name, data in heist_settings.items() - ] - self.add_item(HeistSelect(cog, options)) - - async def interaction_check(self, interaction: discord.Interaction) -> bool: - """Check if the user is allowed to interact.""" - if interaction.user.id not in [self.ctx.author.id, *list(self.cog.bot.owner_ids)]: - await interaction.response.send_message( - "You are not allowed to use this interaction.", ephemeral=True - ) - return False - return True + self.direction = direction + self.heist_view = view - async def on_timeout(self) -> None: - """Handle view timeout.""" - for item in self.children: - item.disabled = True - if self.message: - try: - await self.message.edit(view=self) - except discord.HTTPException as e: - log.error("Failed to update heist view on timeout: %s", e) + async def callback(self, interaction: discord.Interaction): + match self.direction: + case "prev" if self.heist_view.page > 0: + self.heist_view.page -= 1 + case "next" if self.heist_view.page < self.heist_view.total_pages - 1: + self.heist_view.page += 1 + self.heist_view._build_content() + await interaction.response.edit_message(view=self.heist_view) -class HeistSelect(discord.ui.Select): - def __init__(self, cog, options): +class _HeistSelect(discord.ui.Select): + def __init__(self, cog, options: list): self.cog = cog - super().__init__(placeholder="Select a heist...", options=options) + super().__init__(placeholder="Choose your target...", options=options) async def callback(self, interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) @@ -178,10 +98,9 @@ async def callback(self, interaction: discord.Interaction): user = interaction.user if await self.cog._has_active_heist(user, interaction.channel.id): - await interaction.followup.send( + return await interaction.followup.send( "You have an active heist ongoing. Wait for it to finish.", ephemeral=True ) - return now = datetime.datetime.now(datetime.timezone.utc) cooldowns = await self.cog.config.user(user).heist_cooldowns() @@ -190,58 +109,49 @@ async def callback(self, interaction: discord.Interaction): last_time = datetime.datetime.fromtimestamp(last_timestamp, tz=datetime.timezone.utc) if now - last_time < data["cooldown"]: remaining = data["cooldown"] - (now - last_time) - end_time = now + remaining - end_timestamp = int(end_time.timestamp()) - await interaction.followup.send( + end_timestamp = int((now + remaining).timestamp()) + return await interaction.followup.send( f"On cooldown! Ready .", ephemeral=True ) - return balance = await bank.get_balance(user) currency_name = await bank.get_currency_name(interaction.guild) max_loss = data["max_loss"] tax_agreed = False + if balance < max_loss: - view = ConfirmView(user, timeout=60, disable_buttons=True) + view = _ConfirmDebtView(user, timeout=60) tax = int(max_loss * 0.2) total_debt = max_loss + tax - prompt_msg = await interaction.followup.send( - f"Your balance ({balance:,} {currency_name}) is too low to cover a potential loss of up to {max_loss:,} {currency_name} for the {heist_type.replace('_', ' ').title()}. " - f"Proceed and pay up to {total_debt:,} {currency_name} (including 20% tax) later if you fail?\n-# Please note the final price you will need to pay is shown after the heist is done.", + prompt = await interaction.followup.send( + f"⚠️ Your balance ({balance:,} {currency_name}) is too low to cover a potential " + f"loss of up to {max_loss:,} {currency_name}.\n" + f"If you fail, you will owe up to **{total_debt:,}** {currency_name} (including 20% tax).\n" + f"-# The final amount is calculated after the heist.", view=view, ephemeral=True, ) + view.message = prompt await view.wait() - if view.result is None or not view.result: - await prompt_msg.edit( - content="Heist cancelled. You will not be charged when you start a new heist.", - view=None, - ) - return + if not view.confirmed: + return await prompt.edit(content="Heist cancelled.", view=None) tax_agreed = True cooldowns[heist_type] = now.timestamp() await self.cog.config.user(user).heist_cooldowns.set(cooldowns) end_time = now + data["duration"] end_timestamp = int(end_time.timestamp()) - channel_id = interaction.channel.id await self.cog.config.user(user).active_heist.set( { "type": heist_type, "end_time": end_time.timestamp(), - "channel_id": channel_id, + "channel_id": interaction.channel.id, "tax_agreed": tax_agreed, } ) - embed = discord.Embed( - title="Heist Started", - description=f"{data['emoji']} {heist_type.replace('_', ' ').title()} started! Results will be sent .", - color=0x7CFC00, - ) - await interaction.followup.send(embed=embed) - await interaction.followup.send( - f"Started {heist_type.replace('_', ' ').title()}!", ephemeral=True - ) + + started_view = _HeistStartedView(data, heist_type, end_timestamp) + await interaction.followup.send(view=started_view) if user.id in self.cog.pending_tasks: log.warning("Duplicate heist task for user %s, cancelling old", user.id) @@ -256,238 +166,1313 @@ async def callback(self, interaction: discord.Interaction): ) ) self.cog.pending_tasks[user.id] = task - try: + + with contextlib.suppress(discord.HTTPException): for item in self.view.children: item.disabled = True await interaction.message.edit(view=self.view) - except discord.HTTPException as e: - log.error( - "Failed to update heist view after heist start for interaction %s: %s", - interaction.id, - e, - ) -class HeistConfigView(discord.ui.View): - def __init__(self, cog, ctx: commands.Context): +class _HeistStartedView(discord.ui.LayoutView): + def __init__(self, data: dict, heist_type: str, end_timestamp: int): + super().__init__(timeout=None) + body = ( + f"## {data['emoji']} {fmt(heist_type)} - In Progress\n" + f"You're in. No turning back now.\n\n" + f"**Results:** ()\n" + f"**Success chance:** {data['min_success']}–{data['max_success']}%\n" + f"**Potential loss:** {data['min_loss']:,}–{data['max_loss']:,}" + ) + self.add_item(discord.ui.Container(discord.ui.TextDisplay(body))) + + +class HeistSelectionView(discord.ui.LayoutView): + def __init__(self, cog, ctx: commands.Context, heist_settings: dict, currency_name: str): super().__init__(timeout=120) self.cog = cog self.ctx = ctx self.message = None - self.add_item(HeistConfigButton(cog)) + self.currency_name = currency_name + self.all_heists = list(heist_settings.items()) + self.total_pages = max(1, -(-len(self.all_heists) // HEISTS_PER_PAGE)) + self.page = 0 + self._build_content() + + def _build_content(self, disabled: bool = False): + self.clear_items() + start = self.page * HEISTS_PER_PAGE + page_heists = self.all_heists[start : start + HEISTS_PER_PAGE] + + lines = [f"## 🎯 Choose Your Heist - Page {self.page + 1}/{self.total_pages}\n"] + for name, data in page_heists: + loot_item = name if name in ITEMS and ITEMS[name][1].get("type") == "loot" else None + if loot_item: + reward_str = f"{ITEMS[loot_item][1]['min_sell']:,}–{ITEMS[loot_item][1]['max_sell']:,} {self.currency_name} (loot)" + else: + reward_str = f"{data['min_reward']:,}–{data['max_reward']:,} {self.currency_name}" + risk_label = _risk_indicator(data["police_chance"], data["risk"]) + cooldown_str = _cooldown_display(data["cooldown"]) + duration_min = int(data["duration"].total_seconds() // 60) + lines.append( + f"**{data['emoji']} {fmt(name)}** - {risk_label}\n" + f"-# Reward: {reward_str} · Success: {data['min_success']}–{data['max_success']}%" + f" · Cooldown: {cooldown_str} · Duration: {duration_min}m\n" + ) + + options = [ + discord.SelectOption( + label=fmt(name), + value=name, + emoji=data["emoji"], + description=_risk_indicator(data["police_chance"], data["risk"]), + ) + for name, data in page_heists + ] + select = _HeistSelect(self.cog, options) + select.disabled = disabled + select_row = discord.ui.ActionRow(select) + + nav_row = discord.ui.ActionRow() + if self.page > 0: + nav_row.add_item(_HeistNavBtn("prev", self, disabled=disabled)) + if self.page < self.total_pages - 1: + nav_row.add_item(_HeistNavBtn("next", self, disabled=disabled)) + + components: list = [ + discord.ui.TextDisplay("\n".join(lines)), + discord.ui.Separator(), + select_row, + ] + if nav_row.children: + components.append(nav_row) + + self.add_item(discord.ui.Container(*components)) async def interaction_check(self, interaction: discord.Interaction) -> bool: - """Check if the user is allowed to interact.""" if interaction.user.id not in [self.ctx.author.id, *list(self.cog.bot.owner_ids)]: await interaction.response.send_message( - "You are not authorized to use this.", ephemeral=True + "You are not allowed to use this interaction.", ephemeral=True ) return False return True async def on_timeout(self): - for item in self.children: - item.disabled = True + self._build_content(disabled=True) if self.message: with contextlib.suppress(discord.HTTPException): await self.message.edit(view=self) -class HeistConfigButton(discord.ui.Button): - def __init__(self, cog): - super().__init__(label="Configure Settings", style=discord.ButtonStyle.primary) - self.cog = cog +class _ShopNavBtn(discord.ui.Button): + def __init__(self, direction: str, shop_view: "ShopView", disabled: bool = False): + match direction: + case "prev": + label, emoji = "Previous", "◀️" + case "next": + label, emoji = "Next", "▶️" + super().__init__( + label=label, emoji=emoji, style=discord.ButtonStyle.secondary, disabled=disabled + ) + self.direction = direction + self.shop_view = shop_view async def callback(self, interaction: discord.Interaction): - if interaction.user.id not in self.cog.bot.owner_ids: - return await interaction.response.send_message( - "You are not authorized to use this.", ephemeral=True - ) - modal = HeistSetModal(self.cog) - await interaction.response.send_modal(modal) - + match self.direction: + case "prev" if self.shop_view.page > 0: + self.shop_view.page -= 1 + case "next" if self.shop_view.page < len(self.shop_view.pages) - 1: + self.shop_view.page += 1 + # Refresh costs from config so custom prices are always current + self.shop_view.costs = { + name: await self.shop_view.cog.get_item_cost(name) + for name, (_, data) in ITEMS.items() + if "cost" in data + } + self.shop_view._build_content() + await interaction.response.edit_message(view=self.shop_view) -class HeistSetModal(discord.ui.Modal, title="Configure Heist Settings"): - heist_type = discord.ui.TextInput(label="Heist Type", placeholder="e.g. pocket_steal") - param = discord.ui.TextInput(label="Parameter", placeholder="e.g. risk, min_reward") - value = discord.ui.TextInput(label="Value", placeholder="Enter a number (must be seconds)") - def __init__(self, cog): - super().__init__() +class _ShopSelect(discord.ui.Select): + def __init__(self, cog, options: list): self.cog = cog + super().__init__(placeholder="Select an item to purchase...", options=options[:25]) - async def on_submit(self, interaction: discord.Interaction): - heist_type = self.heist_type.value.lower().replace(" ", "_") - param = self.param.value.lower() - try: - value = float(self.value.value) - except ValueError: - return await interaction.response.send_message( - "Invalid value: must be a number.", ephemeral=True - ) + async def callback(self, interaction: discord.Interaction): + item_type = self.values[0] + emoji, _data = ITEMS[item_type] + cost = await self.cog.get_item_cost(item_type) + balance = await bank.get_balance(interaction.user) + currency_name = await bank.get_currency_name(interaction.guild) - if heist_type not in HEISTS: - return await interaction.response.send_message( - f"Invalid heist type: {heist_type}", ephemeral=True - ) - - valid_params = [ - "risk", - "min_reward", - "max_reward", - "cooldown", - "min_success", - "max_success", - "duration", - "police_chance", - "jail_time", - ] - if param not in valid_params: + if balance < cost: return await interaction.response.send_message( - f"Invalid parameter: {param}. Choose from {', '.join(valid_params)}", + f"Not enough funds. Need **{cost:,}** {currency_name}, you have **{balance:,}**.", ephemeral=True, ) - current_settings = await self.cog.config.heist_settings.get_raw(heist_type, default={}) - default_settings = HEISTS.get(heist_type, {}) - - if param in ["risk", "police_chance"]: - value = value / 100.0 - if not 0 <= value <= 1.0: - return await interaction.response.send_message( - f"{param.title()} must be between 0 and 100.", ephemeral=True - ) - elif param in ["min_success", "max_success"]: - value = int(value) - if not 0 <= value <= 100: - return await interaction.response.send_message( - f"{param.title()} must be between 0 and 100.", ephemeral=True - ) - elif param in ["min_reward", "max_reward", "cooldown", "duration", "jail_time"]: - value = int(value) - if value < 0: - return await interaction.response.send_message( - f"{param.title()} cannot be negative.", ephemeral=True - ) - if param in ["cooldown", "jail_time"] and value < 60: - return await interaction.response.send_message( - f"{param.title()} must be at least 60 seconds.", ephemeral=True - ) - if param == "duration" and value < 30: - return await interaction.response.send_message( - "Duration must be at least 30 seconds.", ephemeral=True - ) - - if param == "max_reward": - min_reward = current_settings.get("min_reward", default_settings.get("min_reward", 0)) - if value < min_reward: - return await interaction.response.send_message( - f"Max reward ({value}) cannot be less than min reward ({min_reward}).", - ephemeral=True, - ) - elif param == "min_reward": - max_reward = current_settings.get( - "max_reward", default_settings.get("max_reward", float("inf")) - ) - if value > max_reward: - return await interaction.response.send_message( - f"Min reward ({value}) cannot be greater than max reward ({max_reward}).", - ephemeral=True, - ) - - if param == "max_success": - min_success = current_settings.get( - "min_success", default_settings.get("min_success", 0) - ) - if value < min_success: - return await interaction.response.send_message( - f"Max success ({value}) cannot be less than min success ({min_success}).", - ephemeral=True, - ) - elif param == "min_success": - max_success = current_settings.get( - "max_success", default_settings.get("max_success", 100) - ) - if value > max_success: - return await interaction.response.send_message( - f"Min success ({value}) cannot be greater than max success ({max_success}).", - ephemeral=True, - ) + await bank.withdraw_credits(interaction.user, cost) + inventory = await self.cog.config.user(interaction.user).inventory() + inventory[item_type] = inventory.get(item_type, 0) + 1 + await self.cog.config.user(interaction.user).inventory.set(inventory) - async with self.cog.config.heist_settings() as settings: - settings[heist_type][param] = value await interaction.response.send_message( - f"Set {param} for {heist_type.replace('_', ' ').title()} to {value if param not in ['risk', 'police_chance'] else value * 100}.", + f"Purchased {emoji} **{fmt(item_type)}** for **{cost:,}** {currency_name}. Added to inventory.", ephemeral=True, ) + with contextlib.suppress(discord.HTTPException): + for item in self.view.children: + item.disabled = True + await interaction.message.edit(view=self.view) -class ItemPriceConfigView(discord.ui.View): - def __init__(self, cog, ctx: commands.Context): +class ShopView(discord.ui.LayoutView): + def __init__(self, cog, ctx: commands.Context, currency_name: str, costs: dict): super().__init__(timeout=120) self.cog = cog self.ctx = ctx + self.currency_name = currency_name + self.costs = costs # {item_name: resolved_cost} self.message = None - self.add_item(ItemPriceConfigButton(cog)) + self.pages = [ + (label, itype) + for label, itype in _ALL_SHOP_PAGES + if any(data["type"] == itype and "cost" in data for _, data in ITEMS.values()) + ] + self.page = 0 + self._build_content() + + def _build_content(self, disabled: bool = False): + self.clear_items() + section_label, item_type = self.pages[self.page] + total = len(self.pages) + + items = [ + (name, emoji, data) + for name, (emoji, data) in ITEMS.items() + if data["type"] == item_type and "cost" in data + ] + + lines = [f"## 🛒 Heist Shop - {section_label} ({self.page + 1}/{total})\n"] + for name, emoji, data in items: + cost = self.costs.get(name, data.get("cost", 0)) + line = f"**{emoji} {fmt(name)}** - {cost:,} {self.currency_name}\n" + if item_type == "shield": + line += f"-# Reduces loss by {data['reduction'] * 100:.1f}% (single use)\n" + elif item_type == "tool": + line += f"-# +{data['boost'] * 100:.0f}% success on {fmt(data['for_heist'])} (single use)\n" + elif item_type == "consumable": + line += f"-# Reduces risk by {data['risk_reduction'] * 100:.0f}% (single use)\n" + lines.append(line) + + options = [ + discord.SelectOption( + label=fmt(name), + value=name, + emoji=emoji, + description=( + f"Reduces loss by {data['reduction'] * 100:.1f}%" + if item_type == "shield" + else ( + f"+{data['boost'] * 100:.0f}% for {fmt(data['for_heist'])}" + if item_type == "tool" + else f"Reduces risk by {data['risk_reduction'] * 100:.0f}%" + ) + ), + ) + for name, emoji, data in items + ] + select = _ShopSelect(self.cog, options) + select.disabled = disabled + + nav_row = discord.ui.ActionRow() + if self.page > 0: + nav_row.add_item(_ShopNavBtn("prev", self, disabled=disabled)) + if self.page < len(self.pages) - 1: + nav_row.add_item(_ShopNavBtn("next", self, disabled=disabled)) + + components: list = [ + discord.ui.TextDisplay("".join(lines)), + discord.ui.Separator(), + discord.ui.ActionRow(select), + ] + if nav_row.children: + components.append(nav_row) + + self.add_item(discord.ui.Container(*components)) async def interaction_check(self, interaction: discord.Interaction) -> bool: if interaction.user.id not in [self.ctx.author.id, *list(self.cog.bot.owner_ids)]: await interaction.response.send_message( - "You are not authorized to use this.", ephemeral=True + "You are not allowed to use this interaction.", ephemeral=True ) return False return True async def on_timeout(self): - for item in self.children: - item.disabled = True + self._build_content(disabled=True) if self.message: with contextlib.suppress(discord.HTTPException): await self.message.edit(view=self) -class ItemPriceConfigButton(discord.ui.Button): - def __init__(self, cog): - super().__init__(label="Configure Prices", style=discord.ButtonStyle.primary) - self.cog = cog +class _ConfirmYesBtn(discord.ui.Button): + def __init__(self, cv: "ConfirmLayoutView"): + super().__init__(label="Yes", style=discord.ButtonStyle.success) + self.cv = cv async def callback(self, interaction: discord.Interaction): - if interaction.user.id not in self.cog.bot.owner_ids: - return await interaction.response.send_message( - "You are not authorized to use this.", ephemeral=True - ) - modal = ItemPriceSetModal(self.cog) - await interaction.response.send_modal(modal) + self.cv.confirmed = True + self.cv._disable() + await interaction.response.edit_message(view=self.cv) + self.cv.stop() -class ItemPriceSetModal(discord.ui.Modal, title="Configure Item Price"): - item_name = discord.ui.TextInput(label="Item Name", placeholder="e.g. wooden_shield") - new_cost = discord.ui.TextInput(label="New Cost", placeholder="Enter a positive integer") +class _ConfirmNoBtn(discord.ui.Button): + def __init__(self, cv: "ConfirmLayoutView"): + super().__init__(label="No", style=discord.ButtonStyle.danger) + self.cv = cv - def __init__(self, cog): - super().__init__() - self.cog = cog + async def callback(self, interaction: discord.Interaction): + self.cv.confirmed = False + self.cv._disable() + await interaction.response.edit_message(view=self.cv) + self.cv.stop() - async def on_submit(self, interaction: discord.Interaction): - item_name = self.item_name.value.lower().replace(" ", "_") - try: - value = int(self.new_cost.value) - if value < 0: - raise ValueError - except ValueError: - return await interaction.response.send_message( - "Invalid cost: must be a non-negative integer.", ephemeral=True + +class ConfirmLayoutView(discord.ui.LayoutView): + """Components v2 confirm view.""" + + def __init__(self, user: discord.User, body_text: str, timeout: int = 60): + super().__init__(timeout=timeout) + self.user = user + self.confirmed: bool | None = None + self.message = None + self.add_item( + discord.ui.Container( + discord.ui.TextDisplay(body_text), + discord.ui.Separator(), + discord.ui.ActionRow(_ConfirmYesBtn(self), _ConfirmNoBtn(self)), ) + ) - shop_items = {name: data for name, (_, data) in ITEMS.items() if "cost" in data} - if item_name not in shop_items: - return await interaction.response.send_message( - f"Invalid item: {item_name}", ephemeral=True + def _disable(self): + for item in self.children: + item.disabled = True + + async def on_timeout(self): + self.confirmed = None + self._disable() + if self.message: + with contextlib.suppress(discord.HTTPException): + await self.message.edit(view=self) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.user.id: + await interaction.response.send_message( + "You are not the author of this.", ephemeral=True ) + return False + return True - async with self.cog.config.item_settings() as settings: - settings[item_name]["cost"] = value - await interaction.response.send_message( - f"Set cost for {item_name.replace('_', ' ').title()} to {value:,}.", - ephemeral=True, - ) + +class _ConfirmDebtView(discord.ui.View): + """Lightweight confirm used inside ephemeral followup for debt warning.""" + + def __init__(self, user: discord.User, timeout: int = 60): + super().__init__(timeout=timeout) + self.user = user + self.confirmed: bool | None = None + self.message = None + + @discord.ui.button(label="Proceed anyway", style=discord.ButtonStyle.danger) + async def yes(self, interaction: discord.Interaction, button: discord.ui.Button): + self.confirmed = True + for item in self.children: + item.disabled = True + await interaction.response.edit_message(view=self) + self.stop() + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary) + async def no(self, interaction: discord.Interaction, button: discord.ui.Button): + self.confirmed = False + for item in self.children: + item.disabled = True + await interaction.response.edit_message(view=self) + self.stop() + + async def on_timeout(self): + self.confirmed = None + for item in self.children: + item.disabled = True + if self.message: + with contextlib.suppress(discord.HTTPException): + await self.message.edit(view=self) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.user.id: + await interaction.response.send_message( + "You are not the author of this.", ephemeral=True + ) + return False + return True + + +class _HeistConfigSelect(discord.ui.Select): + def __init__(self, config_view: "HeistConfigView", disabled: bool = False): + self.config_view = config_view + options = [ + discord.SelectOption( + label=fmt(name), + value=name, + emoji=data.get("emoji", "❓"), + default=(config_view.selected_heist == name), + ) + for name, data in HEISTS.items() + ] + super().__init__( + placeholder="Select a heist...", + options=options[:25], + disabled=disabled, + ) + + async def callback(self, interaction: discord.Interaction): + self.config_view.selected_heist = self.values[0] + self.config_view._build_content() + await interaction.response.edit_message(view=self.config_view) + + +class _ParamSelect(discord.ui.Select): + def __init__(self, config_view: "HeistConfigView", disabled: bool = False): + self.config_view = config_view + options = [ + discord.SelectOption( + label=label, + value=key, + description=hint, + default=(config_view.selected_param == key), + ) + for key, (label, hint) in _PARAM_META.items() + ] + super().__init__( + placeholder="Select a parameter...", + options=options, + disabled=disabled, + ) + + async def callback(self, interaction: discord.Interaction): + self.config_view.selected_param = self.values[0] + self.config_view._build_content() + await interaction.response.edit_message(view=self.config_view) + + +class _SetValueBtn(discord.ui.Button): + def __init__(self, config_view: "HeistConfigView", disabled: bool = False): + ready = config_view.selected_heist and config_view.selected_param + super().__init__( + label="Set Value", + style=discord.ButtonStyle.primary, + emoji="✏️", + disabled=disabled or not ready, + ) + self.config_view = config_view + + async def callback(self, interaction: discord.Interaction): + heist = self.config_view.selected_heist + param = self.config_view.selected_param + current = await self.config_view.cog.get_heist_settings(heist) + raw = current.get(param) + # Display friendly current value + if param in ("risk", "police_chance"): + current_str = f"{raw * 100:.1f}" + elif param in ("cooldown", "duration", "jail_time"): + current_str = str(int(raw.total_seconds())) + else: + current_str = str(raw) + await interaction.response.send_modal( + _HeistValueModal(self.config_view.cog, heist, param, current_str) + ) + + +class _HeistValueModal(discord.ui.Modal): + value: discord.ui.TextInput = discord.ui.TextInput( + label="New Value", + placeholder="Enter a number", + max_length=12, + ) + + def __init__(self, cog, heist_type: str, param: str, current_str: str): + label, hint = _PARAM_META[param] + super().__init__(title=f"{fmt(heist_type)} - {label}") + self.cog = cog + self.heist_type = heist_type + self.param = param + self.value.placeholder = f"Current: {current_str} ({hint})" + + async def on_submit(self, interaction: discord.Interaction): + param = self.param + heist_type = self.heist_type + try: + raw = float(self.value.value) + except ValueError: + return await interaction.response.send_message( + "That must be a member.", ephemeral=True + ) + + current_settings = await self.cog.config.heist_settings.get_raw(heist_type, default={}) + default_settings = HEISTS.get(heist_type, {}) + + if param in ("risk", "police_chance"): + v = raw / 100.0 + if not 0 <= v <= 1.0: + return await interaction.response.send_message( + f"{_PARAM_META[param][0]} must be between 0 and 100.", ephemeral=True + ) + elif param in ("min_success", "max_success"): + v = int(raw) + if not 0 <= v <= 100: + return await interaction.response.send_message( + f"{_PARAM_META[param][0]} must be between 0 and 100.", ephemeral=True + ) + else: + v = int(raw) + if v < 0: + return await interaction.response.send_message( + f"{_PARAM_META[param][0]} cannot be negative.", ephemeral=True + ) + if param in ("cooldown", "jail_time") and v < 60: + return await interaction.response.send_message( + f"{_PARAM_META[param][0]} must be at least 60 seconds.", ephemeral=True + ) + if param == "duration" and v < 30: + return await interaction.response.send_message( + "Duration must be at least 30 seconds.", ephemeral=True + ) + + if param == "max_reward": + min_r = current_settings.get("min_reward", default_settings.get("min_reward", 0)) + if v < min_r: + return await interaction.response.send_message( + f"Max reward cannot be less than min reward ({min_r:,}).", ephemeral=True + ) + elif param == "min_reward": + max_r = current_settings.get( + "max_reward", default_settings.get("max_reward", float("inf")) + ) + if v > max_r: + return await interaction.response.send_message( + f"Min reward cannot be greater than max reward ({max_r:,}).", ephemeral=True + ) + if param == "max_success": + min_s = current_settings.get("min_success", default_settings.get("min_success", 0)) + if v < min_s: + return await interaction.response.send_message( + f"Max success cannot be less than min success ({min_s}).", ephemeral=True + ) + elif param == "min_success": + max_s = current_settings.get("max_success", default_settings.get("max_success", 100)) + if v > max_s: + return await interaction.response.send_message( + f"Min success cannot be greater than max success ({max_s}).", ephemeral=True + ) + + async with self.cog.config.heist_settings() as settings: + settings[heist_type][param] = v + + display = f"{v * 100:.1f}%" if param in ("risk", "police_chance") else f"{v:,}" + await interaction.response.send_message( + f"✅ Set **{_PARAM_META[param][0]}** for **{fmt(heist_type)}** to **{display}**.", + ephemeral=True, + ) + + +class HeistConfigView(discord.ui.LayoutView): + """Components v2 heist settings panel.""" + + def __init__(self, cog, ctx: commands.Context): + super().__init__(timeout=180) + self.cog = cog + self.ctx = ctx + self.message = None + self.selected_heist: str | None = None + self.selected_param: str | None = None + self._build_content() + + def _current_summary(self) -> str: + if not self.selected_heist: + return "-# Select a heist and parameter to edit its value." + data = HEISTS.get(self.selected_heist, {}) + emoji = data.get("emoji", "❓") + lines = [f"**{emoji} {fmt(self.selected_heist)}**"] + loot_item = ( + self.selected_heist + if self.selected_heist in ITEMS and ITEMS[self.selected_heist][1].get("type") == "loot" + else None + ) + for key, (label, _) in _PARAM_META.items(): + if key in ("min_reward", "max_reward") and loot_item: + loot_data = ITEMS[loot_item][1] + val = f"{loot_data['min_sell']:,}–{loot_data['max_sell']:,} (loot sell)" + marker = " ◄" if key == self.selected_param else "" + lines.append(f"-# {label}: {val}{marker}") + continue + raw = data.get(key) + if raw is None: + continue + if key in ("risk", "police_chance"): + val = f"{raw * 100:.0f}%" + elif hasattr(raw, "total_seconds"): + val = f"{int(raw.total_seconds())}s" + else: + val = f"{raw:,}" if isinstance(raw, int) else str(raw) + marker = " ◄" if key == self.selected_param else "" + lines.append(f"-# {label}: {val}{marker}") + return "\n".join(lines) + + def _build_content(self, disabled: bool = False): + self.clear_items() + + header = "## ⚙️ Heist Settings" + summary = self._current_summary() + + heist_row = discord.ui.ActionRow(_HeistConfigSelect(self, disabled=disabled)) + param_row = discord.ui.ActionRow(_ParamSelect(self, disabled=disabled)) + btn_row = discord.ui.ActionRow(_SetValueBtn(self, disabled=disabled)) + + self.add_item( + discord.ui.Container( + discord.ui.TextDisplay(f"{header}\n{summary}"), + discord.ui.Separator(), + heist_row, + param_row, + btn_row, + ) + ) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id not in self.cog.bot.owner_ids: + await interaction.response.send_message( + "You are not authorized to use this.", ephemeral=True + ) + return False + return True + + async def on_timeout(self): + self._build_content(disabled=True) + if self.message: + with contextlib.suppress(discord.HTTPException): + await self.message.edit(view=self) + + +class _ItemSelect(discord.ui.Select): + def __init__(self, price_view: "ItemPriceConfigView", page: int, disabled: bool = False): + self.price_view = price_view + page_items = price_view.all_items[page * 25 : (page + 1) * 25] + options = [ + discord.SelectOption( + label=fmt(name), + value=name, + emoji=ITEMS[name][0], + default=(price_view.selected_item == name), + ) + for name, _ in page_items + ] + super().__init__( + placeholder="Select an item...", + options=options, + disabled=disabled, + ) + + async def callback(self, interaction: discord.Interaction): + self.price_view.selected_item = self.values[0] + self.price_view._build_content() + await interaction.response.edit_message(view=self.price_view) + + +class _ItemPageNavBtn(discord.ui.Button): + def __init__(self, direction: str, price_view: "ItemPriceConfigView", disabled: bool = False): + match direction: + case "prev": + label, emoji = "Previous", "◀️" + case "next": + label, emoji = "Next", "▶️" + super().__init__( + label=label, emoji=emoji, style=discord.ButtonStyle.secondary, disabled=disabled + ) + self.direction = direction + self.price_view = price_view + + async def callback(self, interaction: discord.Interaction): + match self.direction: + case "prev" if self.price_view.page > 0: + self.price_view.page -= 1 + case "next" if self.price_view.page < self.price_view.total_pages - 1: + self.price_view.page += 1 + self.price_view._build_content() + await interaction.response.edit_message(view=self.price_view) + + +class _SetPriceBtn(discord.ui.Button): + def __init__(self, price_view: "ItemPriceConfigView", disabled: bool = False): + super().__init__( + label="Set Price", + style=discord.ButtonStyle.primary, + emoji="✏️", + disabled=disabled or not price_view.selected_item, + ) + self.price_view = price_view + + async def callback(self, interaction: discord.Interaction): + item = self.price_view.selected_item + current = await self.price_view.cog.get_item_cost(item) + await interaction.response.send_modal(_ItemPriceModal(self.price_view.cog, item, current)) + + +class _ItemPriceModal(discord.ui.Modal): + new_cost: discord.ui.TextInput = discord.ui.TextInput( + label="New Cost", + placeholder="Enter a positive integer", + max_length=12, + ) + + def __init__(self, cog, item_name: str, current_cost: int): + super().__init__(title=f"Set Price - {fmt(item_name)}") + self.cog = cog + self.item_name = item_name + self.new_cost.placeholder = f"Current: {current_cost:,}" + + async def on_submit(self, interaction: discord.Interaction): + try: + value = int(self.new_cost.value) + if value < 0: + raise ValueError + except ValueError: + return await interaction.response.send_message( + "Must be a non-negative integer.", ephemeral=True + ) + async with self.cog.config.item_settings() as settings: + settings[self.item_name]["cost"] = value + await interaction.response.send_message( + f"✅ Set **{fmt(self.item_name)}** price to **{value:,}**.", ephemeral=True + ) + + +class ItemPriceConfigView(discord.ui.LayoutView): + """Components v2 item price panel.""" + + def __init__(self, cog, ctx: commands.Context): + super().__init__(timeout=180) + self.cog = cog + self.ctx = ctx + self.message = None + self.selected_item: str | None = None + self.all_items = [(name, data) for name, (_, data) in ITEMS.items() if "cost" in data] + self.page = 0 + self.total_pages = max(1, -(-len(self.all_items) // 25)) + self._build_content() + + def _build_content(self, disabled: bool = False): + self.clear_items() + + header = f"## 💰 Item Prices - Page {self.page + 1}/{self.total_pages}" + + page_items = self.all_items[self.page * 25 : (self.page + 1) * 25] + lines = [] + for name, data in page_items: + emoji = ITEMS[name][0] + cost = data.get("cost", 0) + marker = " ◄" if name == self.selected_item else "" + lines.append(f"-# {emoji} {fmt(name)}: {cost:,}{marker}") + + select_row = discord.ui.ActionRow(_ItemSelect(self, self.page, disabled=disabled)) + nav_row = discord.ui.ActionRow() + if self.page > 0: + nav_row.add_item(_ItemPageNavBtn("prev", self, disabled=disabled)) + if self.page < self.total_pages - 1: + nav_row.add_item(_ItemPageNavBtn("next", self, disabled=disabled)) + nav_row.add_item(_SetPriceBtn(self, disabled=disabled)) + + self.add_item( + discord.ui.Container( + discord.ui.TextDisplay(f"{header}\n" + "\n".join(lines)), + discord.ui.Separator(), + select_row, + nav_row, + ) + ) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id not in self.cog.bot.owner_ids: + await interaction.response.send_message( + "You are not authorized to use this.", ephemeral=True + ) + return False + return True + + async def on_timeout(self): + self._build_content(disabled=True) + if self.message: + with contextlib.suppress(discord.HTTPException): + await self.message.edit(view=self) + + +class _JoinCrewBtn(discord.ui.Button): + def __init__(self, lobby: "CrewLobbyView", disabled: bool = False): + super().__init__( + label="Join Crew", + style=discord.ButtonStyle.success, + emoji="🤝", + disabled=disabled, + ) + self.lobby = lobby + + async def callback(self, interaction: discord.Interaction): + user = interaction.user + cog = self.lobby.cog + + if user.id == self.lobby.organiser.id: + return await interaction.response.send_message( + "You're already in the crew as the organiser.", ephemeral=True + ) + if user in self.lobby.members: + return await interaction.response.send_message( + "You've already joined this crew.", ephemeral=True + ) + if len(self.lobby.members) >= CREW_SIZE: + return await interaction.response.send_message( + "The crew is already full.", ephemeral=True + ) + + # Strict checks + if await cog._is_in_jail(user): + return await interaction.response.send_message( + "You can't join a heist while in jail.", ephemeral=True + ) + debt = await cog.config.user(user).debt() + if debt > 0: + return await interaction.response.send_message( + f"You have outstanding debt of {debt:,}. Pay it off first.", ephemeral=True + ) + if await cog._has_active_heist(user, interaction.channel.id): + return await interaction.response.send_message( + "You already have an active heist.", ephemeral=True + ) + + self.lobby.members.append(user) + + # Block joining member from starting another heist while lobby is open + await cog.config.user(user).active_heist.set( + { + "type": "crew_robbery", + "end_time": self.lobby.expires_ts, + "channel_id": interaction.channel.id, + "lobby": True, + } + ) + + self.lobby._build_content() + await interaction.response.edit_message(view=self.lobby) + + +class _BeginCrewBtn(discord.ui.Button): + def __init__(self, lobby: "CrewLobbyView", disabled: bool = False): + super().__init__( + label=f"Begin Heist ({0}/{CREW_SIZE})", + style=discord.ButtonStyle.danger, + emoji="🚀", + disabled=disabled, + ) + self.lobby = lobby + + async def callback(self, interaction: discord.Interaction): + if interaction.user.id != self.lobby.organiser.id: + return await interaction.response.send_message( + "Only the organiser can begin the heist.", ephemeral=True + ) + if len(self.lobby.members) < CREW_SIZE: + return await interaction.response.send_message( + f"Need {CREW_SIZE} crew members. Currently {len(self.lobby.members)}/{CREW_SIZE}.", + ephemeral=True, + ) + + # Final checks on all members + cog = self.lobby.cog + for member in self.lobby.members: + if await cog._is_in_jail(member): + return await interaction.response.send_message( + f"{member.display_name} just got thrown in jail. Crew disbanded.", + ephemeral=True, + ) + if await cog._has_active_heist(member, interaction.channel.id): + return await interaction.response.send_message( + f"{member.display_name} started another heist. Crew disbanded.", + ephemeral=True, + ) + + data = self.lobby.data + now = datetime.datetime.now(datetime.timezone.utc) + end_time = now + data["duration"] + end_timestamp = int(end_time.timestamp()) + + # Set active heist for all members + for member in self.lobby.members: + await cog.config.user(member).active_heist.set( + { + "type": "crew_robbery", + "end_time": end_time.timestamp(), + "channel_id": interaction.channel.id, + "tax_agreed": False, + } + ) + + # Disable the lobby + self.lobby._build_content(disabled=True) + await interaction.response.edit_message(view=self.lobby) + + # Send started confirmation + started_view = _CrewStartedView( + data, self.lobby.members, end_timestamp, self.lobby.currency_name + ) + await interaction.followup.send(view=started_view) + + # Schedule resolve + async def _run(): + await asyncio.sleep(data["duration"].total_seconds()) + from .handlers import resolve_crew_heist + + await resolve_crew_heist(cog, self.lobby.members, "crew_robbery", interaction.channel) + for m in self.lobby.members: + if m.id in cog.pending_tasks: + del cog.pending_tasks[m.id] + + task = asyncio.create_task(_run()) + for member in self.lobby.members: + if member.id in cog.pending_tasks: + log.warning("Duplicate task for crew member %s, cancelling old", member.id) + cog.pending_tasks[member.id].cancel() + cog.pending_tasks[member.id] = task + + +class _CrewStartedView(discord.ui.LayoutView): + def __init__(self, data: dict, members: list, end_timestamp: int, currency_name: str): + super().__init__(timeout=None) + names = ", ".join(m.display_name for m in members) + body = ( + f"## 👥 Crew Robbery - In Progress\n" + f"The crew is in. No turning back now.\n\n" + f"**Crew:** {names}\n" + f"**Results:** ()\n" + f"**Potential haul:** {data['min_reward']:,}–{data['max_reward']:,} {currency_name} (split {len(members)} ways)\n" + f"**Potential loss:** {data['min_loss']:,}–{data['max_loss']:,} {currency_name} (split {len(members)} ways)" + ) + self.add_item(discord.ui.Container(discord.ui.TextDisplay(body))) + + +class CrewLobbyView(discord.ui.LayoutView): + """Components v2 lobby for the crew heist.""" + + def __init__(self, cog, ctx: commands.Context, data: dict, currency_name: str): + super().__init__(timeout=LOBBY_TIMEOUT) + self.cog = cog + self.ctx = ctx + self.data = data + self.currency_name = currency_name + self.organiser = ctx.author + self.members: list[discord.Member] = [ctx.author] + self.message = None + self.expires_ts = int( + datetime.datetime.now(datetime.timezone.utc).timestamp() + LOBBY_TIMEOUT + ) + self._build_content() + + def _build_content(self, disabled: bool = False): + self.clear_items() + filled = len(self.members) + slots = [] + for i in range(CREW_SIZE): + if i < filled: + slots.append(f"**{i + 1}.** {self.members[i].display_name} ✅") + else: + slots.append(f"**{i + 1}.** *Waiting...*") + + expires_ts = self.expires_ts + body = ( + f"## 👥 Crew Robbery - Lobby\n" + f"Need {CREW_SIZE} players to begin. Lobby closes .\n\n" + f"**Potential haul:** {self.data['min_reward']:,}–{self.data['max_reward']:,} {self.currency_name}\n" + f"**Split:** ~{self.data['min_reward'] // CREW_SIZE:,}-{self.data['max_reward'] // CREW_SIZE:,} per person\n" + f"**Risk:** {_risk_indicator(self.data['police_chance'], self.data['risk'])}\n\n" + + "\n".join(slots) + ) + + join_btn = _JoinCrewBtn(self, disabled=disabled or filled >= CREW_SIZE) + begin_btn = _BeginCrewBtn(self, disabled=disabled or filled < CREW_SIZE) + begin_btn.label = f"Begin Heist ({filled}/{CREW_SIZE})" + + action_row = discord.ui.ActionRow(join_btn, begin_btn) + self.add_item( + discord.ui.Container( + discord.ui.TextDisplay(body), + discord.ui.Separator(), + action_row, + ) + ) + + async def on_timeout(self): + self._build_content(disabled=True) + if self.message: + with contextlib.suppress(discord.HTTPException): + await self.message.edit(view=self) + # Clear the lobby placeholder active_heist for all members + for member in self.members: + with contextlib.suppress(Exception): + active = await self.cog.config.user(member).active_heist() + if active and active.get("lobby"): + await self.cog.config.user(member).active_heist.clear() + + +class _EquipSelect(discord.ui.Select): + """Select menu for a single equipment slot, populated from inventory.""" + + def __init__( + self, + cog, + equip_view: "EquipView", + slot: str, + inventory: dict, + equipped: dict, + disabled: bool = False, + ): + self.cog = cog + self.equip_view = equip_view + self.slot = slot + + item_type = slot # slot name matches item type name + options = [ + discord.SelectOption( + label=fmt(name), + value=name, + emoji=ITEMS[name][0], + description=_equip_desc(name, ITEMS[name][1]), + default=(equipped.get(slot) == name), + ) + for name, count in inventory.items() + if count > 0 and name in ITEMS and ITEMS[name][1].get("type") == item_type + ] + + slot_labels = {"shield": "🛡️ Shield", "tool": "🔧 Tool", "consumable": "💊 Consumable"} + if options: + super().__init__( + placeholder=f"Equip a {slot_labels[slot]}...", + options=options[:25], + disabled=disabled, + ) + else: + super().__init__( + placeholder=f"No {slot}s in inventory", + options=[discord.SelectOption(label="None", value="__none__")], + disabled=True, + ) + + async def callback(self, interaction: discord.Interaction): + item = self.values[0] + if item == "__none__": + return await interaction.response.defer() + + equipped = await self.cog.config.user(interaction.user).equipped() + equipped[self.slot] = item + await self.cog.config.user(interaction.user).equipped.set(equipped) + + inventory = await self.cog.config.user(interaction.user).inventory() + self.equip_view._build_content(inventory, equipped) + await interaction.response.edit_message(view=self.equip_view) + + +class _UnequipBtn(discord.ui.Button): + def __init__( + self, cog, equip_view: "EquipView", slot: str, equipped: dict, disabled: bool = False + ): + self.cog = cog + self.equip_view = equip_view + self.slot = slot + slot_labels = {"shield": "Shield", "tool": "Tool", "consumable": "Consumable"} + is_equipped = equipped.get(slot) is not None + super().__init__( + label=f"Unequip {slot_labels[slot]}", + style=discord.ButtonStyle.secondary, + disabled=disabled or not is_equipped, + ) + + async def callback(self, interaction: discord.Interaction): + equipped = await self.cog.config.user(interaction.user).equipped() + equipped[self.slot] = None + await self.cog.config.user(interaction.user).equipped.set(equipped) + + inventory = await self.cog.config.user(interaction.user).inventory() + self.equip_view._build_content(inventory, equipped) + await interaction.response.edit_message(view=self.equip_view) + + +def _equip_desc(name: str, data: dict) -> str: + match data.get("type"): + case "shield": + return f"Reduces loss by {data['reduction'] * 100:.1f}%" + case "tool": + return f"+{data['boost'] * 100:.0f}% for {fmt(data['for_heist'])}" + case "consumable": + return f"Reduces risk by {data['risk_reduction'] * 100:.0f}%" + case _: + return "" + + +class EquipView(discord.ui.LayoutView): + """Components v2 equip panel.""" + + def __init__(self, cog, ctx: commands.Context, inventory: dict, equipped: dict): + super().__init__(timeout=120) + self.cog = cog + self.ctx = ctx + self.message = None + self._build_content(inventory, equipped) + + def _build_content(self, inventory: dict, equipped: dict, disabled: bool = False): + self.clear_items() + + def _slot_line(slot: str) -> str: + item = equipped.get(slot) + if item and item in ITEMS: + emoji = ITEMS[item][0] + return f"**{slot.title()}:** {emoji} {fmt(item)}" + return f"**{slot.title()}:** *None*" + + summary = ( + f"## ⚙️ Equipment\n" + f"{_slot_line('shield')}\n" + f"{_slot_line('tool')}\n" + f"{_slot_line('consumable')}" + ) + + unequip_row = discord.ui.ActionRow( + _UnequipBtn(self.cog, self, "shield", equipped, disabled=disabled), + _UnequipBtn(self.cog, self, "tool", equipped, disabled=disabled), + _UnequipBtn(self.cog, self, "consumable", equipped, disabled=disabled), + ) + + components: list = [ + discord.ui.TextDisplay(summary), + discord.ui.Separator(), + discord.ui.ActionRow( + _EquipSelect(self.cog, self, "shield", inventory, equipped, disabled) + ), + discord.ui.ActionRow( + _EquipSelect(self.cog, self, "tool", inventory, equipped, disabled) + ), + discord.ui.ActionRow( + _EquipSelect(self.cog, self, "consumable", inventory, equipped, disabled) + ), + discord.ui.Separator(), + unequip_row, + ] + self.add_item(discord.ui.Container(*components)) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.ctx.author.id: + await interaction.response.send_message( + "This isn't your equipment panel.", ephemeral=True + ) + return False + return True + + async def on_timeout(self): + equipped = await self.cog.config.user(self.ctx.author).equipped() + inventory = await self.cog.config.user(self.ctx.author).inventory() + self._build_content(inventory, equipped, disabled=True) + if self.message: + with contextlib.suppress(discord.HTTPException): + await self.message.edit(view=self) + + +class _CraftSelect(discord.ui.Select): + def __init__(self, craft_view: "CraftView", craftable: list, disabled: bool = False): + self.craft_view = craft_view + options = [ + discord.SelectOption( + label=fmt(name), + value=name, + emoji=ITEMS[name][0] if name in ITEMS else "🔨", + description=_craft_desc(recipe), + ) + for name, recipe in craftable + ] + super().__init__( + placeholder="Choose a recipe...", + options=options[:25], + disabled=disabled, + ) + + async def callback(self, interaction: discord.Interaction): + self.craft_view.selected = self.values[0] + self.craft_view._build_content() + await interaction.response.edit_message(view=self.craft_view) + + +class _CraftBtn(discord.ui.Button): + def __init__(self, craft_view: "CraftView", disabled: bool = False): + super().__init__( + label="Craft", + style=discord.ButtonStyle.success, + emoji="🔨", + disabled=disabled, + ) + self.craft_view = craft_view + + async def callback(self, interaction: discord.Interaction): + selected = self.craft_view.selected + if not selected: + return await interaction.response.defer() + + recipe = RECIPES[selected] + inventory = await self.craft_view.cog.config.user(interaction.user).inventory() + + missing = [ + f"{fmt(mat)} (need {qty}, have {inventory.get(mat, 0)})" + for mat, qty in recipe["materials"].items() + if inventory.get(mat, 0) < qty + ] + if missing: + return await interaction.response.send_message( + f"Missing materials: {', '.join(missing)}", ephemeral=True + ) + + for mat, qty in recipe["materials"].items(): + inventory[mat] -= qty + if inventory[mat] <= 0: + del inventory[mat] + + result = recipe["result"] + inventory[result] = inventory.get(result, 0) + recipe["quantity"] + await self.craft_view.cog.config.user(interaction.user).inventory.set(inventory) + + # Refresh view with updated inventory + self.craft_view.inventory = inventory + self.craft_view._build_content() + await interaction.response.edit_message(view=self.craft_view) + await interaction.followup.send( + f"✅ Crafted **{recipe['quantity']}× {fmt(result)}**!", ephemeral=True + ) + + +class _CraftNavBtn(discord.ui.Button): + def __init__(self, direction: str, craft_view: "CraftView", disabled: bool = False): + match direction: + case "prev": + label, emoji = "Previous", "◀️" + case "next": + label, emoji = "Next", "▶️" + super().__init__( + label=label, emoji=emoji, style=discord.ButtonStyle.secondary, disabled=disabled + ) + self.direction = direction + self.craft_view = craft_view + + async def callback(self, interaction: discord.Interaction): + match self.direction: + case "prev" if self.craft_view.page > 0: + self.craft_view.page -= 1 + case "next" if self.craft_view.page < self.craft_view.total_pages - 1: + self.craft_view.page += 1 + self.craft_view._build_content() + await interaction.response.edit_message(view=self.craft_view) + + +def _craft_desc(recipe: dict) -> str: + mats = ", ".join(f"{qty}× {fmt(m)}" for m, qty in recipe["materials"].items()) + return mats[:100] + + +RECIPES_PER_PAGE = 10 + + +class CraftView(discord.ui.LayoutView): + """Components v2 crafting panel.""" + + def __init__(self, cog, ctx: commands.Context, inventory: dict): + super().__init__(timeout=120) + self.cog = cog + self.ctx = ctx + self.inventory = inventory + self.selected: str | None = None + self.page = 0 + self.all_recipes = list(RECIPES.items()) + self.total_pages = max(1, -(-len(self.all_recipes) // RECIPES_PER_PAGE)) + self.message = None + self._build_content() + + def _can_craft(self, recipe: dict) -> bool: + return all(self.inventory.get(mat, 0) >= qty for mat, qty in recipe["materials"].items()) + + def _build_content(self, disabled: bool = False): + self.clear_items() + + start = self.page * RECIPES_PER_PAGE + page_recipes = self.all_recipes[start : start + RECIPES_PER_PAGE] + + lines = [f"## 🔨 Crafting - Page {self.page + 1}/{self.total_pages}\n"] + for name, recipe in page_recipes: + emoji = ITEMS[name][0] if name in ITEMS else "🔨" + can = "✅" if self._can_craft(recipe) else "❌" + mats = " · ".join( + f"{qty}× {fmt(m)} ({self.inventory.get(m, 0)} owned)" + for m, qty in recipe["materials"].items() + ) + lines.append(f"**{emoji} {fmt(name)}** {can}\n-# {mats}\n") + + # Detail block for selected recipe + detail = "" + if self.selected and self.selected in RECIPES: + recipe = RECIPES[self.selected] + result_emoji = ITEMS[self.selected][0] if self.selected in ITEMS else "🔨" + mat_lines = "\n".join( + f"-# {fmt(m)}: need {qty}, have {self.inventory.get(m, 0)}" + for m, qty in recipe["materials"].items() + ) + can_craft = self._can_craft(recipe) + detail = ( + f"\n**Selected:** {result_emoji} {fmt(self.selected)} " + f"{'✅ Ready to craft' if can_craft else '❌ Missing materials'}\n{mat_lines}" + ) + + select_row = discord.ui.ActionRow(_CraftSelect(self, page_recipes, disabled=disabled)) + craft_btn = _CraftBtn( + self, + disabled=disabled + or not self.selected + or not self._can_craft(RECIPES.get(self.selected, {})), + ) + nav_row = discord.ui.ActionRow() + if self.page > 0: + nav_row.add_item(_CraftNavBtn("prev", self, disabled=disabled)) + if self.page < self.total_pages - 1: + nav_row.add_item(_CraftNavBtn("next", self, disabled=disabled)) + nav_row.add_item(craft_btn) + + components: list = [ + discord.ui.TextDisplay("".join(lines) + detail), + discord.ui.Separator(), + select_row, + nav_row, + ] + self.add_item(discord.ui.Container(*components)) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.ctx.author.id: + await interaction.response.send_message( + "This isn't your crafting panel.", ephemeral=True + ) + return False + return True + + async def on_timeout(self): + self._build_content(disabled=True) + if self.message: + with contextlib.suppress(discord.HTTPException): + await self.message.edit(view=self) From 19be44986f02342395b5c88162dd8a2f6fef4d16 Mon Sep 17 00:00:00 2001 From: MAX <63972751+ltzmax@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:47:21 +0200 Subject: [PATCH 02/11] Let bot owners run heist event for extra payouts. --- heist/commands/owner_commands.py | 43 +++++++ heist/events.py | 205 +++++++++++++++++++++++++++++++ heist/handlers.py | 30 +++-- heist/heist.py | 4 +- 4 files changed, 271 insertions(+), 11 deletions(-) create mode 100644 heist/events.py diff --git a/heist/commands/owner_commands.py b/heist/commands/owner_commands.py index 27217546..1e164899 100644 --- a/heist/commands/owner_commands.py +++ b/heist/commands/owner_commands.py @@ -27,6 +27,7 @@ import discord from redbot.core import commands +from ..events import EventView, get_event_multiplier from ..utils import HEISTS, ITEMS, fmt from ..views import HeistConfigView, ItemPriceConfigView @@ -224,6 +225,48 @@ async def heistset_show(self, ctx: commands.Context, heist_type: str | None = No ) await ctx.send(embed=embed) + @heistset.group(name="event") + async def heistset_event(self, ctx): + """Manage heist reward events.""" + + @heistset_event.command(name="start") + async def heistset_event_start( + self, ctx: commands.Context, multiplier: int, duration: int + ): + """Start a reward event. + + **Arguments** + - `` Reward multiplier (2–5). + - `` Duration in minutes. + """ + if not 2 <= multiplier <= 5: + return await ctx.send("Multiplier must be between 2 and 5.") + if duration < 1: + return await ctx.send("Duration must be at least 1 minute.") + ends_at = ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=duration) + ).timestamp() + await self.config.event_multiplier.set(multiplier) + await self.config.event_ends_at.set(ends_at) + end_ts = int(ends_at) + await ctx.send( + f"🎉 **{multiplier}x reward event** started! Ends ()." + ) + + @heistset_event.command(name="stop") + async def heistset_event_stop(self, ctx: commands.Context): + """Stop the current reward event.""" + await self.config.event_multiplier.set(1) + await self.config.event_ends_at.set(None) + await ctx.send("Event stopped.") + + @heistset_event.command(name="status") + async def heistset_event_status(self, ctx: commands.Context): + """Show current event status.""" + view = await EventView.create(self, ctx) + view.message = await ctx.send(view=view) + @heistset.command(name="showprices") @commands.bot_has_permissions(embed_links=True) async def heistset_showprices(self, ctx: commands.Context, item_name: str | None = None): diff --git a/heist/events.py b/heist/events.py new file mode 100644 index 00000000..6bceeef6 --- /dev/null +++ b/heist/events.py @@ -0,0 +1,205 @@ +""" +MIT License + +Copyright (c) 2022-present ltzmax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import contextlib +import datetime + +import discord +from red_commons.logging import getLogger + + +log = getLogger("red.cogs.heist.events") + + +async def get_event_multiplier(config) -> tuple[int, float | None]: + """Return (multiplier, ends_at_timestamp) for the active event. + + Returns (1, None) if no event is active or it has expired. + Auto-clears expired events from config. + """ + ends_at = await config.event_ends_at() + if ends_at is None: + return 1, None + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + if now > ends_at: + await config.event_multiplier.set(1) + await config.event_ends_at.set(None) + return 1, None + return await config.event_multiplier(), ends_at + +class _StartEventModal(discord.ui.Modal, title="Start Heist Event"): + multiplier: discord.ui.TextInput = discord.ui.TextInput( + label="Multiplier (2–5)", + placeholder="e.g. 2", + max_length=1, + required=True, + ) + duration: discord.ui.TextInput = discord.ui.TextInput( + label="Duration (minutes)", + placeholder="e.g. 60", + max_length=5, + required=True, + ) + + def __init__(self, event_view: "EventView"): + super().__init__() + self.event_view = event_view + + async def on_submit(self, interaction: discord.Interaction): + try: + mult = int(self.multiplier.value) + if not 2 <= mult <= 5: + raise ValueError + except ValueError: + return await interaction.response.send_message( + "Multiplier must be an integer between 2 and 5.", ephemeral=True + ) + try: + mins = int(self.duration.value) + if mins < 1: + raise ValueError + except ValueError: + return await interaction.response.send_message( + "Duration must be a positive integer (minutes).", ephemeral=True + ) + + ends_at = ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=mins) + ).timestamp() + + await self.event_view.cog.config.event_multiplier.set(mult) + await self.event_view.cog.config.event_ends_at.set(ends_at) + + end_ts = int(ends_at) + await interaction.response.send_message( + f"🎉 **{mult}x reward event** started! Ends ().", + ephemeral=True, + ) + self.event_view._build_content() + with contextlib.suppress(discord.HTTPException): + await self.event_view.message.edit(view=self.event_view) + + +class _StartEventBtn(discord.ui.Button): + def __init__(self, event_view: "EventView", disabled: bool = False): + super().__init__( + label="Start Event", + style=discord.ButtonStyle.success, + emoji="🎉", + disabled=disabled, + ) + self.event_view = event_view + + async def callback(self, interaction: discord.Interaction): + await interaction.response.send_modal(_StartEventModal(self.event_view)) + + +class _StopEventBtn(discord.ui.Button): + def __init__(self, event_view: "EventView", disabled: bool = False): + super().__init__( + label="Stop Event", + style=discord.ButtonStyle.danger, + emoji="🛑", + disabled=disabled, + ) + self.event_view = event_view + + async def callback(self, interaction: discord.Interaction): + await self.event_view.cog.config.event_multiplier.set(1) + await self.event_view.cog.config.event_ends_at.set(None) + await interaction.response.send_message("Event stopped.", ephemeral=True) + self.event_view._build_content() + with contextlib.suppress(discord.HTTPException): + await self.event_view.message.edit(view=self.event_view) + + +class EventView(discord.ui.LayoutView): + """Owner panel for managing heist reward events.""" + + def __init__(self, cog, ctx): + super().__init__(timeout=180) + self.cog = cog + self.ctx = ctx + self.message = None + self._build_content() + + def _build_content(self, disabled: bool = False): + self.clear_items() + mult = self._cached_mult + ends_at = self._cached_ends_at + + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + active = ends_at is not None and mult > 1 and now < ends_at + + if active: + end_ts = int(ends_at) + status = ( + f"## 🎉 Event Active - {mult}x Rewards\n" + f"Ends ()\n\n" + f"-# All heist cash rewards are multiplied by {mult}." + ) + else: + status = ( + "## 🎉 Heist Events\n" + "No event currently active.\n\n" + "-# Start an event to multiply all cash rewards for a set duration." + ) + + action_row = discord.ui.ActionRow( + _StartEventBtn(self, disabled=disabled or active), + _StopEventBtn(self, disabled=disabled or not active), + ) + self.add_item(discord.ui.Container( + discord.ui.TextDisplay(status), + discord.ui.Separator(), + action_row, + )) + + @classmethod + async def create(cls, cog, ctx) -> "EventView": + view = cls.__new__(cls) + discord.ui.LayoutView.__init__(view, timeout=180) + view.cog = cog + view.ctx = ctx + view.message = None + mult, ends_at = await get_event_multiplier(cog.config) + view._cached_mult = mult + view._cached_ends_at = ends_at + view._build_content() + return view + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id not in self.cog.bot.owner_ids: + await interaction.response.send_message( + "You are not authorized to use this.", ephemeral=True + ) + return False + return True + + async def on_timeout(self): + self._build_content(disabled=True) + if self.message: + with contextlib.suppress(discord.HTTPException): + await self.message.edit(view=self) diff --git a/heist/handlers.py b/heist/handlers.py index e4f97312..9525ddfe 100644 --- a/heist/handlers.py +++ b/heist/handlers.py @@ -42,6 +42,7 @@ _FLAVOUR_SUCCESS, _FLAVOUR_TOOL, ) +from .events import get_event_multiplier from .utils import ITEMS, fmt @@ -140,6 +141,7 @@ async def resolve_heist( material_heat = await user_config.material_heat() debt = await user_config.debt() tax_agreed = active.get("tax_agreed", False) + event_multiplier, event_ends_at = await get_event_multiplier(cog.config) tool_boost = 0.0 used_tool = None @@ -176,12 +178,17 @@ async def resolve_heist( await user_config.inventory.set(inventory) msg_parts.append(f"You stole a **{fmt(loot_item)}** from the {fmt(heist_type)}.") else: - reward = random.randint(data["min_reward"], data["max_reward"]) + base_reward = random.randint(data["min_reward"], data["max_reward"]) + reward = base_reward * event_multiplier try: await bank.deposit_credits(member, reward) - msg_parts.append( - f"**+{reward:,} {currency_name}** added to your bank balance." - ) + if event_multiplier > 1: + msg_parts.append( + f"**+{reward:,} {currency_name}** added to your balance. " + f"🎉 {event_multiplier}x event! (base: {base_reward:,})" + ) + else: + msg_parts.append(f"**+{reward:,} {currency_name}** added to your balance.") except errors.BalanceTooHigh: msg_parts.append( f"You would have gained {reward:,} {currency_name}, " @@ -352,13 +359,15 @@ async def resolve_crew_heist( try: data = await cog.get_heist_settings(heist_type) currency_name = await bank.get_currency_name(channel.guild) + event_multiplier, event_ends_at = await get_event_multiplier(cog.config) base_success = random.randint(data["min_success"], data["max_success"]) success_chance = base_success / 100 success = random.random() < success_chance total_reward = 0 if success: - total_reward = random.randint(data["min_reward"], data["max_reward"]) + base_total = random.randint(data["min_reward"], data["max_reward"]) + total_reward = base_total * event_multiplier total_loss = random.randint(data["min_loss"], data["max_loss"]) if not success else 0 per_member_reward = total_reward // len(members) if success else 0 @@ -496,12 +505,13 @@ async def resolve_crew_heist( if success: components.append(discord.ui.Separator()) - components.append( - discord.ui.TextDisplay( - f"**Total haul:** {total_reward:,} {currency_name} split {len(members)} ways\n" - f"**Per member:** ~{per_member_reward:,} {currency_name}" - ) + haul_text = ( + f"**Total haul:** {total_reward:,} {currency_name} split {len(members)} ways\n" + f"**Per member:** ~{per_member_reward:,} {currency_name}" ) + if event_multiplier > 1: + haul_text += f"\n-# 🎉 {event_multiplier}x event active!" + components.append(discord.ui.TextDisplay(haul_text)) else: components.append(discord.ui.Separator()) components.append( diff --git a/heist/heist.py b/heist/heist.py index f2214783..b87b1fac 100644 --- a/heist/heist.py +++ b/heist/heist.py @@ -87,6 +87,8 @@ def __init__(self, bot): "item_settings": { name: {"cost": data["cost"]} for name, (_, data) in ITEMS.items() if "cost" in data }, + "event_multiplier": 1, + "event_ends_at": None, } self.config.register_user(**default_user) self.config.register_global(**default_global) @@ -261,7 +263,7 @@ async def check_jail(self, ctx: commands.Context, jailed_user: discord.Member) - await self.config.user(jailed_user).material_heat.set(0) await view.message.edit( view=_simple_view( - f"{ctx.author.mention} paid **{total_bail:,}** {currency_name} — " + f"{ctx.author.mention} paid **{total_bail:,}** {currency_name} - " f"{'you are' if is_self else f'{jailed_user.display_name} is'} free!" ) ) From 72f46672fab9f8bea99a3102b24758b00397b2e5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:47:30 +0000 Subject: [PATCH 03/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- heist/commands/owner_commands.py | 9 +++------ heist/events.py | 16 +++++++++------- heist/handlers.py | 6 +++--- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/heist/commands/owner_commands.py b/heist/commands/owner_commands.py index 1e164899..0ca72866 100644 --- a/heist/commands/owner_commands.py +++ b/heist/commands/owner_commands.py @@ -27,7 +27,7 @@ import discord from redbot.core import commands -from ..events import EventView, get_event_multiplier +from ..events import EventView from ..utils import HEISTS, ITEMS, fmt from ..views import HeistConfigView, ItemPriceConfigView @@ -230,9 +230,7 @@ async def heistset_event(self, ctx): """Manage heist reward events.""" @heistset_event.command(name="start") - async def heistset_event_start( - self, ctx: commands.Context, multiplier: int, duration: int - ): + async def heistset_event_start(self, ctx: commands.Context, multiplier: int, duration: int): """Start a reward event. **Arguments** @@ -244,8 +242,7 @@ async def heistset_event_start( if duration < 1: return await ctx.send("Duration must be at least 1 minute.") ends_at = ( - datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(minutes=duration) + datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=duration) ).timestamp() await self.config.event_multiplier.set(multiplier) await self.config.event_ends_at.set(ends_at) diff --git a/heist/events.py b/heist/events.py index 6bceeef6..001cc28e 100644 --- a/heist/events.py +++ b/heist/events.py @@ -48,6 +48,7 @@ async def get_event_multiplier(config) -> tuple[int, float | None]: return 1, None return await config.event_multiplier(), ends_at + class _StartEventModal(discord.ui.Modal, title="Start Heist Event"): multiplier: discord.ui.TextInput = discord.ui.TextInput( label="Multiplier (2–5)", @@ -85,8 +86,7 @@ async def on_submit(self, interaction: discord.Interaction): ) ends_at = ( - datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(minutes=mins) + datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=mins) ).timestamp() await self.event_view.cog.config.event_multiplier.set(mult) @@ -171,11 +171,13 @@ def _build_content(self, disabled: bool = False): _StartEventBtn(self, disabled=disabled or active), _StopEventBtn(self, disabled=disabled or not active), ) - self.add_item(discord.ui.Container( - discord.ui.TextDisplay(status), - discord.ui.Separator(), - action_row, - )) + self.add_item( + discord.ui.Container( + discord.ui.TextDisplay(status), + discord.ui.Separator(), + action_row, + ) + ) @classmethod async def create(cls, cog, ctx) -> "EventView": diff --git a/heist/handlers.py b/heist/handlers.py index 9525ddfe..f3be7b4e 100644 --- a/heist/handlers.py +++ b/heist/handlers.py @@ -31,6 +31,7 @@ from red_commons.logging import getLogger from redbot.core import bank, errors +from .events import get_event_multiplier from .meta import ( _CREW_FLAVOUR_CAUGHT, _CREW_FLAVOUR_FAIL, @@ -42,7 +43,6 @@ _FLAVOUR_SUCCESS, _FLAVOUR_TOOL, ) -from .events import get_event_multiplier from .utils import ITEMS, fmt @@ -141,7 +141,7 @@ async def resolve_heist( material_heat = await user_config.material_heat() debt = await user_config.debt() tax_agreed = active.get("tax_agreed", False) - event_multiplier, event_ends_at = await get_event_multiplier(cog.config) + event_multiplier, _event_ends_at = await get_event_multiplier(cog.config) tool_boost = 0.0 used_tool = None @@ -359,7 +359,7 @@ async def resolve_crew_heist( try: data = await cog.get_heist_settings(heist_type) currency_name = await bank.get_currency_name(channel.guild) - event_multiplier, event_ends_at = await get_event_multiplier(cog.config) + event_multiplier, _event_ends_at = await get_event_multiplier(cog.config) base_success = random.randint(data["min_success"], data["max_success"]) success_chance = base_success / 100 success = random.random() < success_chance From cf445ccdddb87ffdc3c3ffe4b042ded22574767b Mon Sep 17 00:00:00 2001 From: MAX <63972751+ltzmax@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:03:46 +0200 Subject: [PATCH 04/11] Add some heist stats - just small begin but probably to improve overtime. --- heist/commands/user_commands.py | 12 +++++++++++- heist/handlers.py | 16 ++++++++++++++++ heist/heist.py | 5 +++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/heist/commands/user_commands.py b/heist/commands/user_commands.py index 5b90bf74..2ee16cba 100644 --- a/heist/commands/user_commands.py +++ b/heist/commands/user_commands.py @@ -27,6 +27,7 @@ import discord from redbot.core import bank, commands +from redbot.core.utils.chat_formatting import humanize_number from ..utils import HEISTS, ITEMS, fmt from ..views import CraftView, CrewLobbyView, EquipView, HeistSelectionView, ShopView @@ -322,6 +323,7 @@ async def heist_status(self, ctx: commands.Context): active = await self.config.user(ctx.author).active_heist() jail = await self.config.user(ctx.author).jail() heat = await self._get_effective_heat(ctx.author) + stats = await self.config.user(ctx.author).stats() lines = [f"## 📊 {ctx.author.display_name}'s Profile"] @@ -350,7 +352,15 @@ async def heist_status(self, ctx: commands.Context): else: lines.append("\n**🚨 Jail:** Free") - lines.append(f"\n**🌡️ Heat:** {_heat_bar(heat)}") + total = stats.get("success", 0) + stats.get("fail", 0) + stats.get("caught", 0) + lines.append( + f"\n**📈 Heist Stats**\n" + f"✅ Success: {humanize_number(stats.get('success', 0))}\n" + f"❌ Failed: {humanize_number(stats.get('fail', 0))}\n" + f"🚨 Caught: {humanize_number(stats.get('caught', 0))}\n" + f"Total: {humanize_number(total)}" + ) + lines.append(f"\n**🌡️ Heat:**\n{_heat_bar(heat)}") view = discord.ui.LayoutView(timeout=None) view.add_item(discord.ui.Container(discord.ui.TextDisplay("\n".join(lines)))) await ctx.send(view=view) diff --git a/heist/handlers.py b/heist/handlers.py index f3be7b4e..c7878605 100644 --- a/heist/handlers.py +++ b/heist/handlers.py @@ -317,6 +317,15 @@ async def resolve_heist( else: colour = 0xFF6600 + # Update lifetime stats + async with user_config.stats() as stats: + if caught: + stats["caught"] = stats.get("caught", 0) + 1 + elif success: + stats["success"] = stats.get("success", 0) + 1 + else: + stats["fail"] = stats.get("fail", 0) + 1 + heist_emoji = data.get("emoji", "🎭") result_view = _build_result_view( heist_type=heist_type, @@ -482,6 +491,13 @@ async def resolve_crew_heist( lines.append(f"-# Found {qty}× {fmt(dropped)}") await user_config.active_heist.clear() + async with user_config.stats() as stats: + if member_caught: + stats["caught"] = stats.get("caught", 0) + 1 + elif success: + stats["success"] = stats.get("success", 0) + 1 + else: + stats["fail"] = stats.get("fail", 0) + 1 member_lines.append("\n".join(lines)) if any_caught: diff --git a/heist/heist.py b/heist/heist.py index b87b1fac..9c5484a5 100644 --- a/heist/heist.py +++ b/heist/heist.py @@ -68,6 +68,11 @@ def __init__(self, bot): "heat": 0, "material_heat": 0, "heat_last_set": None, + "stats": { + "success": 0, + "fail": 0, + "caught": 0, + }, } default_global = { "heist_settings": { From 1d3e6cb1d1bb785f0c723b14063a614b7251204a Mon Sep 17 00:00:00 2001 From: MAX <63972751+ltzmax@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:28:58 +0200 Subject: [PATCH 05/11] Dont need extra commands when one does it all. --- heist/commands/owner_commands.py | 31 +------------------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/heist/commands/owner_commands.py b/heist/commands/owner_commands.py index 0ca72866..5071cb24 100644 --- a/heist/commands/owner_commands.py +++ b/heist/commands/owner_commands.py @@ -230,37 +230,8 @@ async def heistset_event(self, ctx): """Manage heist reward events.""" @heistset_event.command(name="start") - async def heistset_event_start(self, ctx: commands.Context, multiplier: int, duration: int): - """Start a reward event. - - **Arguments** - - `` Reward multiplier (2–5). - - `` Duration in minutes. - """ - if not 2 <= multiplier <= 5: - return await ctx.send("Multiplier must be between 2 and 5.") - if duration < 1: - return await ctx.send("Duration must be at least 1 minute.") - ends_at = ( - datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=duration) - ).timestamp() - await self.config.event_multiplier.set(multiplier) - await self.config.event_ends_at.set(ends_at) - end_ts = int(ends_at) - await ctx.send( - f"🎉 **{multiplier}x reward event** started! Ends ()." - ) - - @heistset_event.command(name="stop") - async def heistset_event_stop(self, ctx: commands.Context): - """Stop the current reward event.""" - await self.config.event_multiplier.set(1) - await self.config.event_ends_at.set(None) - await ctx.send("Event stopped.") - - @heistset_event.command(name="status") async def heistset_event_status(self, ctx: commands.Context): - """Show current event status.""" + """Show current event and start/stop one.""" view = await EventView.create(self, ctx) view.message = await ctx.send(view=view) From 70a0e33e6e15f9e6cdd917825ee6defdf25e5747 Mon Sep 17 00:00:00 2001 From: MAX <63972751+ltzmax@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:01:50 +0200 Subject: [PATCH 06/11] Added leveling... decided to do this lol --- heist/commands/user_commands.py | 48 ++++++++++- heist/events.py | 1 - heist/handlers.py | 34 +++++++- heist/heist.py | 3 +- heist/leveling.py | 137 ++++++++++++++++++++++++++++++++ heist/utils.py | 32 +++++++- heist/views.py | 28 ++++++- 7 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 heist/leveling.py diff --git a/heist/commands/user_commands.py b/heist/commands/user_commands.py index 2ee16cba..6ddb8b5e 100644 --- a/heist/commands/user_commands.py +++ b/heist/commands/user_commands.py @@ -29,6 +29,7 @@ from redbot.core import bank, commands from redbot.core.utils.chat_formatting import humanize_number +from ..leveling import MAX_LEVEL, get_level, level_success_bonus, xp_bar, xp_progress from ..utils import HEISTS, ITEMS, fmt from ..views import CraftView, CrewLobbyView, EquipView, HeistSelectionView, ShopView @@ -144,7 +145,9 @@ async def do_heist(self, ctx: commands.Context): } currency_name = await bank.get_currency_name(ctx.guild) - view = HeistSelectionView(self, ctx, heist_settings, currency_name) + player_xp = await self.config.user(ctx.author).xp() + player_level = get_level(player_xp) + view = HeistSelectionView(self, ctx, heist_settings, currency_name, player_level) view.message = await ctx.send(view=view) @heist.command(name="crew") @@ -361,6 +364,49 @@ async def heist_status(self, ctx: commands.Context): f"Total: {humanize_number(total)}" ) lines.append(f"\n**🌡️ Heat:**\n{_heat_bar(heat)}") + + xp = await self.config.user(ctx.author).xp() + lvl, into, span, pct = xp_progress(xp) + lv_bonus = level_success_bonus(lvl) + lines.append( + f"\n**🎓 Level {lvl}** / {MAX_LEVEL}\n" + f"{xp_bar(pct)} {humanize_number(into)}/{humanize_number(span)} XP" + + (f" · +{lv_bonus * 100:.0f}% success bonus" if lv_bonus > 0 else "") + ) + + view = discord.ui.LayoutView(timeout=None) + view.add_item(discord.ui.Container(discord.ui.TextDisplay("\n".join(lines)))) + await ctx.send(view=view) + + @heist.command(name="level") + async def heist_level(self, ctx: commands.Context, member: discord.Member = commands.Author): + """Check heist level and XP progress.""" + xp = await self.config.user(member).xp() + lvl, into, span, pct = xp_progress(xp) + lv_bonus = level_success_bonus(lvl) + + # This will need to be here, please do not remove or move. + from ..leveling import XP_TABLE + + if lvl >= MAX_LEVEL: + next_line = "-# Max level reached!" + else: + xp_needed = XP_TABLE[lvl] - xp + next_line = f"-# {humanize_number(xp_needed)} XP until level {lvl + 1}" + + lines = [ + f"## 🎓 {member.display_name}'s Level", + f"\n**Level {lvl}** / {MAX_LEVEL}", + f"{xp_bar(pct)} {humanize_number(into)}/{humanize_number(span)} XP", + next_line, + ] + if lv_bonus > 0: + lines.append(f"\n**Bonus:** +{lv_bonus * 100:.0f}% success chance on all heists") + else: + lines.append( + "\n-# Earn XP by completing heists to gain success bonuses (+0.5% per level, max +20% at Lv.40)" + ) + view = discord.ui.LayoutView(timeout=None) view.add_item(discord.ui.Container(discord.ui.TextDisplay("\n".join(lines)))) await ctx.send(view=view) diff --git a/heist/events.py b/heist/events.py index 001cc28e..2136aff5 100644 --- a/heist/events.py +++ b/heist/events.py @@ -28,7 +28,6 @@ import discord from red_commons.logging import getLogger - log = getLogger("red.cogs.heist.events") diff --git a/heist/handlers.py b/heist/handlers.py index c7878605..7c4c9ab5 100644 --- a/heist/handlers.py +++ b/heist/handlers.py @@ -32,6 +32,7 @@ from redbot.core import bank, errors from .events import get_event_multiplier +from .leveling import award_xp, level_success_bonus, xp_bar, xp_progress from .meta import ( _CREW_FLAVOUR_CAUGHT, _CREW_FLAVOUR_FAIL, @@ -45,7 +46,6 @@ ) from .utils import ITEMS, fmt - log = getLogger("red.cogs.heist.handlers") @@ -159,8 +159,12 @@ async def resolve_heist( inventory.pop(used_tool, None) await user_config.inventory.set(inventory) + member_xp = await user_config.xp() + member_level = xp_progress(member_xp)[0] + lv_bonus = level_success_bonus(member_level) + base_success = random.randint(data["min_success"], data["max_success"]) - success_chance = min((base_success + int(tool_boost * 100)) / 100, 1.0) + success_chance = min((base_success + int(tool_boost * 100)) / 100 + lv_bonus, 1.0) success = random.random() < success_chance loot_item = ( @@ -326,6 +330,20 @@ async def resolve_heist( else: stats["fail"] = stats.get("fail", 0) + 1 + # Award XP + old_level, new_level, xp_gained = await award_xp(cog, member, heist_type, success, caught) + new_xp = await user_config.xp() + lvl, into, span, pct = xp_progress(new_xp) + if caught: + msg_parts.append("-# 🎓 No XP earned (caught)") + else: + level_up_str = ( + f" — **Level up! {old_level} -> {new_level}** 🎉" if new_level > old_level else "" + ) + msg_parts.append( + f"-# 🎓 +{xp_gained} XP{level_up_str} · Lv.{lvl} {xp_bar(pct)} {into:,}/{span:,}" + ) + heist_emoji = data.get("emoji", "🎭") result_view = _build_result_view( heist_type=heist_type, @@ -498,6 +516,18 @@ async def resolve_crew_heist( stats["success"] = stats.get("success", 0) + 1 else: stats["fail"] = stats.get("fail", 0) + 1 + + old_lv, new_lv, xp_gained = await award_xp( + cog, member, heist_type, success, member_caught + ) + new_xp = await cog.config.user(member).xp() + lvl, _into, _span, _pct = xp_progress(new_xp) + if member_caught: + lines.append("-# 🎓 No XP earned (caught)") + else: + lv_up = f" · Level up! {old_lv} -> {new_lv} 🎉" if new_lv > old_lv else "" + lines.append(f"-# 🎓 +{xp_gained} XP · Lv.{lvl}{lv_up}") + member_lines.append("\n".join(lines)) if any_caught: diff --git a/heist/heist.py b/heist/heist.py index 9c5484a5..157cceef 100644 --- a/heist/heist.py +++ b/heist/heist.py @@ -36,7 +36,6 @@ from .utils import HEISTS, ITEMS from .views import ConfirmLayoutView - log = getLogger("red.cogs.heist") @@ -73,6 +72,7 @@ def __init__(self, bot): "fail": 0, "caught": 0, }, + "xp": 0, } default_global = { "heist_settings": { @@ -302,6 +302,7 @@ async def get_heist_settings(self, heist_type: str) -> dict: "police_chance": custom.get("police_chance", defaults.get("police_chance", 0.0)), "material_drop_chance": defaults.get("material_drop_chance", 0.0), "material_tiers": defaults.get("material_tiers"), + "xp_reward": defaults.get("xp_reward", 50), "jail_time": datetime.timedelta( seconds=custom.get( "jail_time", diff --git a/heist/leveling.py b/heist/leveling.py new file mode 100644 index 00000000..da941204 --- /dev/null +++ b/heist/leveling.py @@ -0,0 +1,137 @@ +""" +MIT License + +Copyright (c) 2022-present ltzmax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import math + +from red_commons.logging import getLogger + +log = getLogger("red.cogs.heist.leveling") + +MAX_LEVEL = 120 + +# XP table +# WoW-style: each level requires progressively more XP. +# Formula: xp_for_level(n) = floor(100 * n * (1 + 0.12 * n)) +# Level 1 -> 2: 212 XP, Level 50 -> 51: ~36,200 XP, Level 119 -> 120: ~207,000 XP + + +def _compute_threshold(level: int) -> int: + """XP required to reach `level` from level 1 (cumulative).""" + return sum(math.floor(100 * n * (1 + 0.12 * n)) for n in range(1, level)) + + +# Precompute cumulative XP thresholds for all levels. +# XP_TABLE[i] = total XP needed to be at level i+1. +# XP_TABLE[0] = 0 (level 1 starts at 0 XP) +XP_TABLE: list[int] = [_compute_threshold(lvl) for lvl in range(1, MAX_LEVEL + 2)] + + +def get_level(total_xp: int) -> int: + """Return the current level for a given total XP amount.""" + level = 1 + for lvl in range(MAX_LEVEL, 0, -1): + if total_xp >= XP_TABLE[lvl - 1]: + level = lvl + break + return min(level, MAX_LEVEL) + + +def xp_for_next_level(total_xp: int) -> int: + """Return XP needed to reach the next level from current total.""" + level = get_level(total_xp) + if level >= MAX_LEVEL: + return 0 + return XP_TABLE[level] - total_xp + + +def xp_progress(total_xp: int) -> tuple[int, int, int, float]: + """Return (level, xp_into_level, xp_needed_for_level, pct). + + xp_into_level: XP earned since start of current level. + xp_needed_for_level: total XP span of current level. + pct: 0.0–1.0 progress through current level. + """ + level = get_level(total_xp) + if level >= MAX_LEVEL: + return MAX_LEVEL, 0, 0, 1.0 + level_start = XP_TABLE[level - 1] + level_end = XP_TABLE[level] + span = level_end - level_start + into = total_xp - level_start + pct = into / span if span > 0 else 1.0 + return level, into, span, pct + + +def level_success_bonus(level: int) -> float: + """Return success chance bonus from level (0.0–0.20). + + +0.5% per level, capped at +20% at level 40. + """ + return min(level * 0.005, 0.20) + + +def xp_bar(pct: float, length: int = 20) -> str: + """Dot progress bar for XP.""" + filled = min(round(pct * length), length) + bar = "●" * filled + "○" * (length - filled) + return f"`{bar}` {pct * 100:.1f}%" + + +async def award_xp( + cog, + member, + heist_type: str, + success: bool, + caught: bool, +) -> tuple[int, int, int]: + """Award XP for a heist outcome. Returns (old_level, new_level, xp_gained). + + - Caught: 0 XP + - Fail: 20% of base XP + - Success: full base XP + """ + if caught: + return ( + get_level(await cog.config.user(member).xp()), + get_level(await cog.config.user(member).xp()), + 0, + ) + + from .utils import HEISTS + + heist_data = HEISTS.get(heist_type, {}) + base_xp = heist_data.get("xp_reward", 50) + + xp_gained = base_xp if success else max(1, int(base_xp * 0.20)) + old_xp = await cog.config.user(member).xp() + old_level = get_level(old_xp) + new_xp = old_xp + xp_gained + new_level = min(get_level(new_xp), MAX_LEVEL) + + # Cap XP at max level threshold + if new_level >= MAX_LEVEL: + new_xp = min(new_xp, XP_TABLE[MAX_LEVEL - 1]) + + await cog.config.user(member).xp.set(new_xp) + return old_level, new_level, xp_gained diff --git a/heist/utils.py b/heist/utils.py index 8acd09ad..475d0876 100644 --- a/heist/utils.py +++ b/heist/utils.py @@ -446,7 +446,7 @@ def fmt(s: str) -> str: "min_reward": 0, "max_reward": 300, "cooldown": datetime.timedelta(minutes=30), - "min_success": 50, + "min_success": 70, "max_success": 80, "duration": datetime.timedelta(minutes=2), "min_loss": 100, @@ -454,6 +454,7 @@ def fmt(s: str) -> str: "police_chance": 0.05, "jail_time": datetime.timedelta(hours=1), "material_drop_chance": 0.2, + "xp_reward": 15, }, "atm_smash": { "emoji": "🏧", @@ -461,7 +462,7 @@ def fmt(s: str) -> str: "min_reward": 500, "max_reward": 3000, "cooldown": datetime.timedelta(minutes=45), - "min_success": 35, + "min_success": 40, "max_success": 65, "duration": datetime.timedelta(minutes=3), "min_loss": 300, @@ -469,6 +470,7 @@ def fmt(s: str) -> str: "police_chance": 0.05, "jail_time": datetime.timedelta(hours=2), "material_drop_chance": 0.25, + "xp_reward": 30, }, "store_robbery": { "emoji": "🏬", @@ -476,7 +478,7 @@ def fmt(s: str) -> str: "min_reward": 1000, "max_reward": 5000, "cooldown": datetime.timedelta(hours=1), - "min_success": 30, + "min_success": 50, "max_success": 60, "duration": datetime.timedelta(minutes=5), "min_loss": 500, @@ -484,6 +486,7 @@ def fmt(s: str) -> str: "police_chance": 0.15, "jail_time": datetime.timedelta(hours=3), "material_drop_chance": 0.3, + "xp_reward": 40, }, "jewelry_store": { "emoji": "💎", @@ -499,6 +502,7 @@ def fmt(s: str) -> str: "police_chance": 0.2, "jail_time": datetime.timedelta(hours=4), "material_drop_chance": 0.35, + "xp_reward": 60, }, "fight_club": { "emoji": "🥊", @@ -514,6 +518,7 @@ def fmt(s: str) -> str: "police_chance": 0.25, "jail_time": datetime.timedelta(hours=5), "material_drop_chance": 0.4, + "xp_reward": 75, }, "art_gallery": { "emoji": "🖼️", @@ -529,6 +534,7 @@ def fmt(s: str) -> str: "police_chance": 0.3, "jail_time": datetime.timedelta(hours=6), "material_drop_chance": 0.45, + "xp_reward": 90, }, "casino_vault": { "emoji": "🎰", @@ -544,6 +550,7 @@ def fmt(s: str) -> str: "police_chance": 0.35, "jail_time": datetime.timedelta(hours=8), "material_drop_chance": 0.5, + "xp_reward": 120, }, "museum_relic": { "emoji": "🏺", @@ -559,6 +566,7 @@ def fmt(s: str) -> str: "police_chance": 0.4, "jail_time": datetime.timedelta(hours=12), "material_drop_chance": 0.55, + "xp_reward": 120, }, "luxury_yacht": { "emoji": "🛥️", @@ -574,6 +582,7 @@ def fmt(s: str) -> str: "police_chance": 0.45, "jail_time": datetime.timedelta(hours=24), "material_drop_chance": 0.6, + "xp_reward": 120, }, "street_bike": { "emoji": "🚲", @@ -589,6 +598,7 @@ def fmt(s: str) -> str: "police_chance": 0.1, "jail_time": datetime.timedelta(hours=2), "material_drop_chance": 0.15, + "xp_reward": 25, }, "street_motorcycle": { "emoji": "🏍️", @@ -604,6 +614,7 @@ def fmt(s: str) -> str: "police_chance": 0.15, "jail_time": datetime.timedelta(hours=3), "material_drop_chance": 0.2, + "xp_reward": 35, }, "street_car": { "emoji": "🚗", @@ -619,6 +630,7 @@ def fmt(s: str) -> str: "police_chance": 0.2, "jail_time": datetime.timedelta(hours=4), "material_drop_chance": 0.25, + "xp_reward": 50, }, "corporate": { "emoji": "🏢", @@ -634,6 +646,7 @@ def fmt(s: str) -> str: "police_chance": 0.3, "jail_time": datetime.timedelta(hours=8), "material_drop_chance": 0.35, + "xp_reward": 100, }, "bank": { "emoji": "🏦", @@ -649,6 +662,7 @@ def fmt(s: str) -> str: "police_chance": 0.35, "jail_time": datetime.timedelta(hours=12), "material_drop_chance": 0.45, + "xp_reward": 160, }, "elite": { "emoji": "💎", @@ -664,6 +678,7 @@ def fmt(s: str) -> str: "police_chance": 0.4, "jail_time": datetime.timedelta(hours=12), "material_drop_chance": 0.5, + "xp_reward": 200, }, "crew_robbery": { "emoji": "👥", @@ -679,6 +694,7 @@ def fmt(s: str) -> str: "police_chance": 0.45, "jail_time": datetime.timedelta(hours=16), "material_drop_chance": 0.6, + "xp_reward": 300, "crew_size": 4, }, "vending_machine": { @@ -687,7 +703,7 @@ def fmt(s: str) -> str: "min_reward": 10, "max_reward": 80, "cooldown": datetime.timedelta(minutes=10), - "min_success": 70, + "min_success": 80, "max_success": 95, "duration": datetime.timedelta(seconds=30), "min_loss": 5, @@ -695,6 +711,7 @@ def fmt(s: str) -> str: "police_chance": 0.01, "jail_time": datetime.timedelta(minutes=15), "material_drop_chance": 0.05, + "xp_reward": 9, }, "parking_meter": { "emoji": "🅿️", @@ -710,6 +727,7 @@ def fmt(s: str) -> str: "police_chance": 0.03, "jail_time": datetime.timedelta(minutes=30), "material_drop_chance": 0.08, + "xp_reward": 8, }, "food_truck": { "emoji": "🚚", @@ -725,6 +743,7 @@ def fmt(s: str) -> str: "police_chance": 0.06, "jail_time": datetime.timedelta(hours=1), "material_drop_chance": 0.15, + "xp_reward": 20, }, "hospital_pharmacy": { "emoji": "🏥", @@ -740,6 +759,7 @@ def fmt(s: str) -> str: "police_chance": 0.18, "jail_time": datetime.timedelta(hours=4), "material_drop_chance": 0.3, + "xp_reward": 50, }, "stock_exchange": { "emoji": "📈", @@ -755,6 +775,7 @@ def fmt(s: str) -> str: "police_chance": 0.22, "jail_time": datetime.timedelta(hours=6), "material_drop_chance": 0.35, + "xp_reward": 85, }, "gold_reserve": { "emoji": "🪙", @@ -770,6 +791,7 @@ def fmt(s: str) -> str: "police_chance": 0.38, "jail_time": datetime.timedelta(hours=10), "material_drop_chance": 0.45, + "xp_reward": 130, "material_tiers": ["scrap_metal", "tech_parts", "rare_alloy", "military_grade_alloy"], }, "military_depot": { @@ -786,6 +808,7 @@ def fmt(s: str) -> str: "police_chance": 0.42, "jail_time": datetime.timedelta(hours=14), "material_drop_chance": 0.5, + "xp_reward": 150, "material_tiers": ["rare_alloy", "classified_docs", "military_grade_alloy"], }, "space_agency": { @@ -802,6 +825,7 @@ def fmt(s: str) -> str: "police_chance": 0.5, "jail_time": datetime.timedelta(hours=24), "material_drop_chance": 0.55, + "xp_reward": 220, "material_tiers": ["rare_alloy", "classified_docs", "military_grade_alloy"], }, } diff --git a/heist/views.py b/heist/views.py index 0bdaec71..bf4182a1 100644 --- a/heist/views.py +++ b/heist/views.py @@ -34,7 +34,6 @@ from .meta import _PARAM_META from .utils import HEISTS, ITEMS, RECIPES, fmt - log = getLogger("red.cogs.heist.views") CREW_SIZE = 4 LOBBY_TIMEOUT = 180 # 3 minutes @@ -187,12 +186,20 @@ def __init__(self, data: dict, heist_type: str, end_timestamp: int): class HeistSelectionView(discord.ui.LayoutView): - def __init__(self, cog, ctx: commands.Context, heist_settings: dict, currency_name: str): + def __init__( + self, + cog, + ctx: commands.Context, + heist_settings: dict, + currency_name: str, + player_level: int = 1, + ): super().__init__(timeout=120) self.cog = cog self.ctx = ctx self.message = None self.currency_name = currency_name + self.player_level = player_level self.all_heists = list(heist_settings.items()) self.total_pages = max(1, -(-len(self.all_heists) // HEISTS_PER_PAGE)) self.page = 0 @@ -203,7 +210,18 @@ def _build_content(self, disabled: bool = False): start = self.page * HEISTS_PER_PAGE page_heists = self.all_heists[start : start + HEISTS_PER_PAGE] - lines = [f"## 🎯 Choose Your Heist - Page {self.page + 1}/{self.total_pages}\n"] + from .leveling import level_success_bonus + + lv_bonus = level_success_bonus(self.player_level) + lv_str = ( + f"+{lv_bonus * 100:.0f}% from Lv.{self.player_level}" + if lv_bonus > 0 + else f"Lv.{self.player_level}" + ) + + lines = [ + f"## 🎯 Choose Your Heist - Page {self.page + 1}/{self.total_pages} · 🎓 {lv_str}\n" + ] for name, data in page_heists: loot_item = name if name in ITEMS and ITEMS[name][1].get("type") == "loot" else None if loot_item: @@ -213,9 +231,11 @@ def _build_content(self, disabled: bool = False): risk_label = _risk_indicator(data["police_chance"], data["risk"]) cooldown_str = _cooldown_display(data["cooldown"]) duration_min = int(data["duration"].total_seconds() // 60) + eff_min = min(data["min_success"] + int(lv_bonus * 100), 100) + eff_max = min(data["max_success"] + int(lv_bonus * 100), 100) lines.append( f"**{data['emoji']} {fmt(name)}** - {risk_label}\n" - f"-# Reward: {reward_str} · Success: {data['min_success']}–{data['max_success']}%" + f"-# Reward: {reward_str} · Success: {eff_min}–{eff_max}%" f" · Cooldown: {cooldown_str} · Duration: {duration_min}m\n" ) From 62dfdd28bd329da826c0181d13560a22f5c78b7f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:02:03 +0000 Subject: [PATCH 07/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- heist/events.py | 1 + heist/handlers.py | 1 + heist/heist.py | 1 + heist/leveling.py | 1 + heist/views.py | 1 + 5 files changed, 5 insertions(+) diff --git a/heist/events.py b/heist/events.py index 2136aff5..001cc28e 100644 --- a/heist/events.py +++ b/heist/events.py @@ -28,6 +28,7 @@ import discord from red_commons.logging import getLogger + log = getLogger("red.cogs.heist.events") diff --git a/heist/handlers.py b/heist/handlers.py index 7c4c9ab5..a55459ae 100644 --- a/heist/handlers.py +++ b/heist/handlers.py @@ -46,6 +46,7 @@ ) from .utils import ITEMS, fmt + log = getLogger("red.cogs.heist.handlers") diff --git a/heist/heist.py b/heist/heist.py index 157cceef..2f89c850 100644 --- a/heist/heist.py +++ b/heist/heist.py @@ -36,6 +36,7 @@ from .utils import HEISTS, ITEMS from .views import ConfirmLayoutView + log = getLogger("red.cogs.heist") diff --git a/heist/leveling.py b/heist/leveling.py index da941204..302d2b2f 100644 --- a/heist/leveling.py +++ b/heist/leveling.py @@ -26,6 +26,7 @@ from red_commons.logging import getLogger + log = getLogger("red.cogs.heist.leveling") MAX_LEVEL = 120 diff --git a/heist/views.py b/heist/views.py index bf4182a1..eb8832be 100644 --- a/heist/views.py +++ b/heist/views.py @@ -34,6 +34,7 @@ from .meta import _PARAM_META from .utils import HEISTS, ITEMS, RECIPES, fmt + log = getLogger("red.cogs.heist.views") CREW_SIZE = 4 LOBBY_TIMEOUT = 180 # 3 minutes From 21a37653fc00da4fb86a066d66a021d94fe220a6 Mon Sep 17 00:00:00 2001 From: MAX <63972751+ltzmax@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:06:53 +0200 Subject: [PATCH 08/11] Be at least level 20 for crew robbery game. --- heist/commands/user_commands.py | 4 ++++ heist/views.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/heist/commands/user_commands.py b/heist/commands/user_commands.py index 6ddb8b5e..8f440c59 100644 --- a/heist/commands/user_commands.py +++ b/heist/commands/user_commands.py @@ -164,6 +164,10 @@ async def crew_heist(self, ctx: commands.Context): return if not await self.check_jail(ctx, ctx.author): return + player_xp = await self.config.user(ctx.author).xp() + if get_level(player_xp) < 20: + return await ctx.send("You must be **level 20** or higher to organise a crew robbery.") + if await self._has_active_heist(ctx.author, ctx.channel.id): return await ctx.send("You have an active heist ongoing. Wait for it to finish.") diff --git a/heist/views.py b/heist/views.py index bf4182a1..4c1ee6da 100644 --- a/heist/views.py +++ b/heist/views.py @@ -977,6 +977,12 @@ async def callback(self, interaction: discord.Interaction): "You already have an active heist.", ephemeral=True ) + xp = await cog.config.user(user).xp() + if get_level(xp) < 20: + return await interaction.response.send_message( + "You must be **level 20** or higher to join a crew robbery.", ephemeral=True + ) + self.lobby.members.append(user) # Block joining member from starting another heist while lobby is open From 073f567607a043d714da9c12b54f25620176f745 Mon Sep 17 00:00:00 2001 From: MAX <63972751+ltzmax@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:09:24 +0200 Subject: [PATCH 09/11] import fix --- heist/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/heist/views.py b/heist/views.py index a4b8d3d3..d2c266e0 100644 --- a/heist/views.py +++ b/heist/views.py @@ -31,6 +31,7 @@ from redbot.core import bank, commands from .handlers import schedule_resolve +from .leveling import get_level from .meta import _PARAM_META from .utils import HEISTS, ITEMS, RECIPES, fmt From c3c4d705f314a94ff4f24f9f9d08d2f1e536e775 Mon Sep 17 00:00:00 2001 From: MAX <63972751+ltzmax@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:19:07 +0200 Subject: [PATCH 10/11] add typing on these 3 commands. --- heist/commands/user_commands.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/heist/commands/user_commands.py b/heist/commands/user_commands.py index 8f440c59..cc5290b0 100644 --- a/heist/commands/user_commands.py +++ b/heist/commands/user_commands.py @@ -327,6 +327,7 @@ async def check_shield(self, ctx: commands.Context): @heist.command(name="profile") async def heist_status(self, ctx: commands.Context): """Check your active heist profile.""" + await ctx.typing() active = await self.config.user(ctx.author).active_heist() jail = await self.config.user(ctx.author).jail() heat = await self._get_effective_heat(ctx.author) @@ -385,6 +386,7 @@ async def heist_status(self, ctx: commands.Context): @heist.command(name="level") async def heist_level(self, ctx: commands.Context, member: discord.Member = commands.Author): """Check heist level and XP progress.""" + await ctx.typing() xp = await self.config.user(member).xp() lvl, into, span, pct = xp_progress(xp) lv_bonus = level_success_bonus(lvl) @@ -419,6 +421,7 @@ async def heist_level(self, ctx: commands.Context, member: discord.Member = comm @commands.bot_has_permissions(embed_links=True) async def check_cooldowns(self, ctx: commands.Context): """Check cooldowns for all heists.""" + await ctx.typing() if not await self.check_jail(ctx, ctx.author): return cooldowns = await self.config.user(ctx.author).heist_cooldowns() From f29dd0e32abb06ffc642d1dcefae580583a939b2 Mon Sep 17 00:00:00 2001 From: MAX <63972751+ltzmax@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:24:20 +0200 Subject: [PATCH 11/11] fix equip not auto remove. --- heist/handlers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/heist/handlers.py b/heist/handlers.py index a55459ae..5380e7b6 100644 --- a/heist/handlers.py +++ b/heist/handlers.py @@ -158,6 +158,8 @@ async def resolve_heist( inventory[used_tool] = max(0, inventory.get(used_tool, 0) - 1) if inventory[used_tool] == 0: inventory.pop(used_tool, None) + equipped["tool"] = None + await user_config.equipped.set(equipped) await user_config.inventory.set(inventory) member_xp = await user_config.xp() @@ -210,6 +212,8 @@ async def resolve_heist( inventory[equipped_shield] = max(0, inventory.get(equipped_shield, 0) - 1) if inventory[equipped_shield] == 0: inventory.pop(equipped_shield, None) + equipped["shield"] = None + await user_config.equipped.set(equipped) await user_config.inventory.set(inventory) msg_parts.append(random.choice(_FLAVOUR_SHIELD))