diff --git a/README.md b/README.md
index 8132ae2..816dece 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,64 @@
-# Ruby Cogs
+# Ruby Cogs ๐ค
+[](https://discord.gg/red)
+[](https://www.python.org/)
+[](LICENSE)
+[](LICENSE)
-## This repository is being specifically gathered and modified for our instance of Red-DiscordBot
-- This will contain every cog we use on our instance called 'Ruby', and modified accordingly for our use.
+## ๐ About
+This repository contains a curated collection of cogs specifically gathered and modified for our instance of Red-DiscordBot named 'Ruby'. Each cog has been carefully selected and customized to enhance our bot's functionality.
-Credits:
-[AAA3A Cogs](https://github.com/AAA3A-AAA3A/AAA3A-cogs)
-[Aikaterna's Cogs](https://github.com/aikaterna/aikaterna-cogs)
-[Ben Cogs](https://github.com/BenCos17/ben-cogs)
-[Crab Cogs](https://github.com/hollowstrawberry/crab-cogs)
-[Dav Cogs](https://github.com/Dav-Git/Dav-Cogs)
-[Flame Cogs](https://github.com/Flame442/FlameCogs)
-[Flare Cogs](https://github.com/flaree/Flare-Cogs)
-[Flare's Pokecord-Red](https://github.com/flaree/pokecord-red)
-[Fluffy Cogs](https://github.com/zephyrkul/FluffyCogs)
-[Jojo Cogs](https://github.com/Just-Jojo/JojoCogs)
-[Kreusada Cogs](https://github.com/kreusada/Kreusada-Cogs)
-[Kuro Cogs](https://github.com/Kuro-Rui/Kuro-Cogs)
-[Laggron's Dumb Cogs](https://github.com/laggron42/Laggrons-Dumb-Cogs)
-[Max Cogs](https://github.com/ltzmax/maxcogs)
-[MayuYukirin Cogs](https://github.com/skeith/MayuYukirin)
-[Palmtree5 Cogs](https://github.com/palmtree5/palmtree5-cogs)
-[PhasecoreX Cogs](https://github.com/PhasecoreX/PCXCogs)
-[Preda's Cogs](https://github.com/PredaaA/predacogs)
-[T14D3 Cogs](https://github.com/T14D3/T14D3-Cogs)
-[Trusty Cogs](https://github.com/TrustyJAID/Trusty-cogs)
-[Vert Cogs](https://github.com/vertyco/vrt-cogs)
-[Vex Cogs](https://github.com/Vexed01/Vex-Cogs)
-[x26 Cogs](https://github.com/Twentysix26/x26-Cogs)
-[Toxic Cogs](https://github.com/NeuroAssassin/Toxic-Cogs)
-[Karlo Cogs](https://github.com/karlsbjorn/karlo-cogs)
-[Mister-42 Cogs](https://github.com/Mister-42/mr42-cogs)
\ No newline at end of file
+## ๐ฎ Features
+- Carefully selected cogs from the Red-DiscordBot community
+- Custom modifications for optimal performance
+- Regular updates and maintenance
+- Wide variety of functionality
+
+## ๐ Credits
+We extend our gratitude to the following developers and their amazing contributions:
+
+### Administrative & Utility
+- [AAA3A Cogs](https://github.com/AAA3A-AAA3A/AAA3A-cogs) - Comprehensive administrative tools
+- [Aikaterna's Cogs](https://github.com/aikaterna/aikaterna-cogs) - Versatile utility cogs
+- [PhasecoreX Cogs](https://github.com/PhasecoreX/PCXCogs) - Enhanced bot management
+
+### Gaming & Entertainment
+- [Flare's Pokecord-Red](https://github.com/flaree/pokecord-red) - Pokemon collection and battles
+- [Flame Cogs](https://github.com/Flame442/FlameCogs) - Gaming and entertainment features
+- [Calamari Cogs](https://github.com/CalaMariGold/CalaMari-Cogs) - Fun and engaging additions
+
+### Community & Moderation
+- [Trusty Cogs](https://github.com/TrustyJAID/Trusty-cogs) - Reliable moderation tools
+- [Laggron's Dumb Cogs](https://github.com/laggron42/Laggrons-Dumb-Cogs) - Community management
+- [Vert Cogs](https://github.com/vertyco/vrt-cogs) - Server enhancement tools
+
+### Additional Resources
+- [Ben Cogs](https://github.com/BenCos17/ben-cogs)
+- [Crab Cogs](https://github.com/hollowstrawberry/crab-cogs)
+- [Dav Cogs](https://github.com/Dav-Git/Dav-Cogs)
+- [Flare Cogs](https://github.com/flaree/Flare-Cogs)
+- [Fluffy Cogs](https://github.com/zephyrkul/FluffyCogs)
+- [Jojo Cogs](https://github.com/Just-Jojo/JojoCogs)
+- [Kreusada Cogs](https://github.com/kreusada/Kreusada-Cogs)
+- [Kuro Cogs](https://github.com/Kuro-Rui/Kuro-Cogs)
+- [Max Cogs](https://github.com/ltzmax/maxcogs)
+- [MayuYukirin Cogs](https://github.com/skeith/MayuYukirin)
+- [Palmtree5 Cogs](https://github.com/palmtree5/palmtree5-cogs)
+- [Preda's Cogs](https://github.com/PredaaA/predacogs)
+- [T14D3 Cogs](https://github.com/T14D3/T14D3-Cogs)
+- [Vex Cogs](https://github.com/Vexed01/Vex-Cogs)
+- [x26 Cogs](https://github.com/Twentysix26/x26-Cogs)
+- [Toxic Cogs](https://github.com/NeuroAssassin/Toxic-Cogs)
+- [Karlo Cogs](https://github.com/karlsbjorn/karlo-cogs)
+- [Mister-42 Cogs](https://github.com/Mister-42/mr42-cogs)
+
+## ๐ Licenses
+This repository includes cogs that may be licensed under either:
+- [MIT License](LICENSE)
+- [GNU General Public License v3.0 (GPL-3.0)](LICENSE)
+
+Please check individual cog directories for their specific license information.
+
+---
+
+Made with โค๏ธ for Ruby Discord Bot
+
\ No newline at end of file
diff --git a/city/README.md b/city/README.md
new file mode 100644
index 0000000..2b100bf
--- /dev/null
+++ b/city/README.md
@@ -0,0 +1,120 @@
+# City
+
+A comprehensive city simulation system with various activities (currently only contains crime module). Features a crime system for earning credits through virtual crimes with different risk levels, cooldowns, rewards, jail system, jail breaks, dynamic events, and custom scenarios.
+
+## Features
+
+- ๐ฆนโโ๏ธ Multiple crime types with varying risk levels
+- ๐ดโโ ๏ธ Black Market shop for special items and perks
+- ๐ Personal inventory system
+- โ๏ธ Jail system with bail and jailbreak mechanics
+- ๐ Detailed statistics and leaderboards
+- ๐ฐ Dynamic reward system
+- โ๏ธ Risk vs. reward gameplay
+- ๐ฏ Target other users for crimes
+- ๐ฒ Custom random scenarios with server-specific events
+
+- ๐ฎ **Button/Dropdown-Based Interface**
+ - Modern, intuitive button controls
+ - Color-coded risk levels
+ - Dynamic button states based on user status
+ - Clean, organized menu system
+
+- ๐ **Statistics & Leaderboards**
+ - Track successful and failed crimes
+ - Monitor lifetime earnings and largest heists
+ - View server-wide crime leaderboards
+ - Personal criminal status tracking
+
+## Installation
+
+1. Make sure you have Red-DiscordBot V3 installed
+2. Add this repository: `[p]repo add CalaMari-Cogs https://github.com/CalaMariGold/CalaMari-Cogs`
+3. Install the cog: `[p]cog install CalaMari-Cogs city`
+
+## Setup
+
+1. Load the cog: `[p]load city`
+2. Make sure the bot has required permissions:
+ - Manage Messages (for button interactions)
+ - Send Messages
+ - Embed Links
+ - Add Reactions
+3. Register your bank if not already done: `[p]bank register`
+4. Customize settings as desired using the `[p]crimeset` commands
+
+## Donate
+
+If you enjoy using this cog, you can support my work:
+
+[](https://ko-fi.com/calamarigold)
+
+## Support
+
+If you encounter any issues or have suggestions, please open an issue on the GitHub repository.
+
+## Usage
+
+### Crime Types
+
+#### ๐งค Pickpocket
+- Low risk, targeted crime
+- Steal percentage of target's balance
+- Quick cooldown
+- Short jail time if caught
+
+#### ๐ช Mugging
+- Medium risk, targeted crime
+- Steal percentage of target's balance
+- Medium cooldown
+- Moderate jail time
+
+#### ๐ช Store Robbery
+- Medium risk, non-targeted
+- Fixed reward range
+- Medium cooldown
+- Moderate jail time
+
+#### ๐๏ธ Bank Heist
+- High risk, non-targeted
+- Highest potential rewards
+- Long cooldown
+- Longest jail time
+
+#### ๐ฒ Random Crime
+- Random risk level
+- Dynamic scenarios (both default and custom)
+- Varying rewards and penalties
+- Unique events and outcomes
+- Server admins can create custom scenarios
+
+### Commands
+
+**User Commands:**
+- `[p]crime` - Open the main crime menu with all available actions
+ - `commit` - Choose and commit a crime
+ - `status` - View your criminal status and stats
+ - `bail` - Pay to get out of jail early
+ - `jailbreak` - Attempt to escape from jail
+ - `leaderboard` - View the server's crime leaderboard
+
+**Admin Commands:**
+- `[p]crimeset` - Configure crime settings
+ - `success_rate ` - Set success rate (0.0 to 1.0)
+ - `reward ` - Set reward range
+ - `cooldown ` - Set cooldown duration
+ - `jailtime ` - Set jail time duration
+ - `fine ` - Set fine multiplier
+ - `reload_defaults` - Reset to default settings
+ - `scenarios` - Manage custom random scenarios:
+ - `add` - Create a new custom scenario
+ - `list` - View all custom scenarios
+ - `remove ` - Remove a custom scenario
+ - `global` - Configure global settings:
+ - `bailcost ` - Set bail cost multiplier
+ - `togglebail ` - Enable/disable bail system
+ - `view` - View all current settings
+
+**Owner Commands:**
+- `[p]wipecitydata ` - Wipe a user's city data
+- `[p]wipecityallusers` - Wipe ALL city data (requires confirmation)
diff --git a/city/__init__.py b/city/__init__.py
new file mode 100644
index 0000000..095f7ca
--- /dev/null
+++ b/city/__init__.py
@@ -0,0 +1,30 @@
+"""City cog for Red-DiscordBot."""
+
+from redbot.core.bot import Red
+from redbot.core import commands, bank, Config
+import discord
+
+from .base import CityBase
+from .crime import CrimeCommands
+from .crime.data import CRIME_TYPES, DEFAULT_GUILD, DEFAULT_MEMBER
+
+class City(CityBase, CrimeCommands, commands.Cog):
+ """A virtual city where you can commit crimes, work jobs, and more."""
+
+ def __init__(self, bot: Red) -> None:
+ super().__init__(bot)
+ CrimeCommands.__init__(self, bot)
+ self.bot = bot
+
+ # Store crime types
+ self.crime_types = CRIME_TYPES.copy()
+
+ # Register default guild settings
+ self.config.register_guild(**DEFAULT_GUILD)
+
+ # Register crime-specific member settings
+ self.config.register_member(**DEFAULT_MEMBER)
+
+async def setup(bot: Red):
+ """Load City."""
+ await bot.add_cog(City(bot))
diff --git a/city/base.py b/city/base.py
new file mode 100644
index 0000000..580380b
--- /dev/null
+++ b/city/base.py
@@ -0,0 +1,512 @@
+"""Base cog for city-related features."""
+
+from redbot.core import commands, bank, Config
+from redbot.core.bot import Red
+import discord
+from datetime import datetime, timezone
+import time
+from .crime.data import CRIME_TYPES, DEFAULT_GUILD, DEFAULT_MEMBER
+from typing import Dict, Any
+
+CONFIG_SCHEMA = {
+ "GUILD": {
+ "crime_options": {}, # Crime configuration from crime/data.py
+ "global_settings": {}, # Global settings from crime/data.py including:
+ # - Jail and bail settings
+ # - Crime targeting rules
+ # - Notification settings (cost and toggle)
+ "blackmarket_items": {} # Blackmarket items configuration
+ },
+ "MEMBER": {
+ "jail_until": 0, # Unix timestamp when jail sentence ends
+ "last_actions": {}, # Dict mapping action_type -> last_timestamp
+ "total_successful_crimes": 0, # Total number of successful crimes
+ "total_failed_crimes": 0, # Total number of failed crimes
+ "total_fines_paid": 0, # Total amount paid in fines
+ "total_credits_earned": 0, # Total credits earned from all sources
+ "total_stolen_from": 0, # Credits stolen from other users
+ "total_stolen_by": 0, # Credits stolen by other users
+ "total_bail_paid": 0, # Total amount spent on bail
+ "largest_heist": 0, # Largest successful heist amount
+ "last_target": None, # ID of last targeted user (anti-farming)
+ "notify_unlocked": False, # Whether notifications feature is unlocked
+ "notify_on_release": False, # Whether notifications are currently enabled
+ "jail_channel": None, # ID of channel where user was jailed (for notifications)
+ "attempted_jailbreak": False, # Whether user attempted jailbreak this sentence
+ "purchased_perks": [], # List of permanent perks owned from blackmarket
+ "active_items": {} # Dict of temporary items with expiry timestamps or uses
+ }
+}
+
+class CityBase:
+ """Base class for city-related features."""
+
+ def __init__(self, bot: Red) -> None:
+ self.bot = bot
+ self.config = Config.get_conf(
+ self,
+ identifier=95932766180343808,
+ force_registration=True
+ )
+
+ # Register defaults - combine CONFIG_SCHEMA and crime defaults
+ guild_defaults = {**CONFIG_SCHEMA["GUILD"], **DEFAULT_GUILD}
+ member_defaults = {**CONFIG_SCHEMA["MEMBER"], **DEFAULT_MEMBER}
+
+ self.config.register_guild(**guild_defaults)
+ self.config.register_member(**member_defaults)
+
+ # Config schema version
+ self.CONFIG_SCHEMA = 3
+
+ # Track active tasks
+ self.tasks = []
+
+ @commands.group(name="city", invoke_without_command=True)
+ async def city(self, ctx: commands.Context):
+ """Access the city system."""
+ if ctx.invoked_subcommand is None:
+ await ctx.send_help(ctx.command)
+
+ async def red_delete_data_for_user(self, *, requester, user_id: int):
+ """Delete user data when requested."""
+ # Delete member data
+ await self.config.member_from_ids(None, user_id).clear()
+
+ # Remove user from other members' last_target
+ all_members = await self.config.all_members()
+ for guild_id, guild_data in all_members.items():
+ for member_id, member_data in guild_data.items():
+ if member_data.get("last_target") == user_id:
+ await self.config.member_from_ids(guild_id, member_id).last_target.set(None)
+
+ async def get_jail_time_remaining(self, member: discord.Member) -> int:
+ """Get remaining jail time in seconds."""
+ jail_until = await self.config.member(member).jail_until()
+ if not jail_until:
+ return 0
+
+ current_time = int(time.time())
+ remaining = max(0, jail_until - current_time)
+
+ # Clear jail if time is up
+ if remaining == 0 and jail_until != 0:
+ await self.config.member(member).jail_until.set(0)
+
+ return remaining
+
+ async def get_remaining_cooldown(self, member: discord.Member, action_type: str) -> int:
+ """Get remaining cooldown time for an action."""
+ current_time = int(time.time())
+ last_actions = await self.config.member(member).last_actions()
+
+ # Get last attempt time and cooldown
+ last_attempt = last_actions.get(action_type, 0)
+ if not last_attempt:
+ return 0
+
+ # Get crime data for cooldown duration
+ crime_options = await self.config.guild(member.guild).crime_options()
+ if action_type not in crime_options:
+ return 0
+
+ cooldown = crime_options[action_type]["cooldown"]
+ remaining = max(0, cooldown - (current_time - last_attempt))
+
+ return remaining
+
+ async def set_action_cooldown(self, member: discord.Member, action_type: str):
+ """Set cooldown for an action."""
+ current_time = int(time.time())
+ async with self.config.member(member).last_actions() as last_actions:
+ last_actions[action_type] = current_time
+
+ async def is_jailed(self, member: discord.Member) -> bool:
+ """Check if a member is currently jailed."""
+ remaining = await self.get_jail_time_remaining(member)
+ return remaining > 0
+
+ async def apply_fine(self, member: discord.Member, crime_type: str, crime_data: dict) -> tuple[bool, int]:
+ """Apply a fine to a user. Returns (paid_successfully, amount)."""
+ fine_amount = int(crime_data["max_reward"] * crime_data["fine_multiplier"])
+
+ try:
+ # Try to take full fine
+ await bank.withdraw_credits(member, fine_amount)
+
+ # Update stats
+ async with self.config.member(member).all() as member_data:
+ member_data["total_fines_paid"] += fine_amount
+
+ return True, fine_amount
+ except ValueError:
+ # If they can't pay full fine, take what they have
+ try:
+ balance = await bank.get_balance(member)
+ if balance > 0:
+ await bank.withdraw_credits(member, balance)
+
+ # Update stats with partial payment
+ async with self.config.member(member).all() as member_data:
+ member_data["total_fines_paid"] += balance
+
+ return False, fine_amount
+ except Exception as e:
+ return False, fine_amount
+
+ async def handle_target_crime(
+ self,
+ member: discord.Member,
+ target: discord.Member,
+ crime_data: dict,
+ success: bool
+ ) -> tuple[int, str]:
+ """Handle a targeted crime attempt. Returns (amount, message)."""
+ settings = await self.config.guild(member.guild).global_settings()
+
+ if success:
+ # Calculate amount to steal
+ amount = await calculate_stolen_amount(target, crime_data, settings)
+
+ try:
+ # Check target's balance first
+ target_balance = await bank.get_balance(target)
+ if target_balance < amount:
+ return 0, _("Target doesn't have enough {currency}!").format(currency=await bank.get_currency_name(target.guild))
+
+ # Perform the transfer atomically
+ await bank.withdraw_credits(target, amount)
+ await bank.deposit_credits(member, amount)
+
+ # Update stats only after successful transfer
+ async with self.config.member(member).all() as member_data:
+ member_data["total_stolen_from"] += amount
+ member_data["total_credits_earned"] += amount
+ member_data["last_target"] = target.id
+ if amount > member_data["largest_heist"]:
+ member_data["largest_heist"] = amount
+
+ async with self.config.member(target).all() as target_data:
+ target_data["total_stolen_by"] += amount
+
+ return amount, _("๐ You successfully stole {amount:,} {currency} from {target}!").format(
+ amount=amount,
+ target=target.mention,
+ currency=await bank.get_currency_name(target.guild)
+ )
+ except ValueError as e:
+ return 0, _("Failed to steal credits: Balance changed!")
+ else:
+ # Failed attempt
+ fine_paid, fine_amount = await self.apply_fine(member, crime_data["crime_type"], crime_data)
+
+ # Update stats
+ async with self.config.member(member).all() as member_data:
+ member_data["total_failed_crimes"] += 1
+
+ if fine_paid:
+ return 0, _("๐ You were caught trying to steal from {target}! You paid a fine of {fine:,} {currency} and were sent to jail for {minutes}m!").format(
+ target=target.display_name,
+ fine=fine_amount,
+ minutes=crime_data["jail_time"] // 60,
+ currency=await bank.get_currency_name(target.guild)
+ )
+ else:
+ return 0, _("๐ You were caught and couldn't pay the {fine:,} credit fine! You were sent to jail for {minutes}m!").format(
+ fine=fine_amount,
+ minutes=crime_data["jail_time"] // 60
+ )
+
+ async def cog_unload(self):
+ """Clean up when cog is unloaded."""
+ for task in self.tasks:
+ task.cancel()
+ class ConfirmWipeView(discord.ui.View):
+ def __init__(self, ctx: commands.Context, user: discord.Member):
+ super().__init__(timeout=30.0) # 30 second timeout
+ self.ctx = ctx
+ self.user = user
+ self.value = None
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ # Only allow the original command author to use the buttons
+ return interaction.user.id == self.ctx.author.id
+
+ @discord.ui.button(label='Confirm Wipe', style=discord.ButtonStyle.danger)
+ async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
+ self.value = True
+ self.stop()
+ # Disable all buttons after clicking
+ for item in self.children:
+ item.disabled = True
+ await interaction.response.edit_message(view=self)
+
+ @discord.ui.button(label='Cancel', style=discord.ButtonStyle.grey)
+ async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
+ self.value = False
+ self.stop()
+ # Disable all buttons after clicking
+ for item in self.children:
+ item.disabled = True
+ await interaction.response.edit_message(view=self)
+
+ async def on_timeout(self) -> None:
+ # Disable all buttons if the view times out
+ for item in self.children:
+ item.disabled = True
+ # Try to update the message with disabled buttons
+ try:
+ await self.message.edit(view=self)
+ except discord.NotFound:
+ pass
+
+ class ConfirmGlobalWipeView(discord.ui.View):
+ def __init__(self, ctx: commands.Context):
+ super().__init__(timeout=30.0) # 30 second timeout
+ self.ctx = ctx
+ self.value = None
+ self.confirmation_phrase = None
+ self.waiting_for_confirmation = False
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ # Only allow the original command author to use the buttons
+ return interaction.user.id == self.ctx.author.id
+
+ @discord.ui.button(label='I Understand - Proceed to Confirmation', style=discord.ButtonStyle.danger)
+ async def confirm_understanding(self, interaction: discord.Interaction, button: discord.ui.Button):
+ if self.waiting_for_confirmation:
+ return
+
+ self.waiting_for_confirmation = True
+
+ # Generate a random confirmation phrase
+ import random
+ import string
+ import asyncio
+ self.confirmation_phrase = ''.join(random.choices(string.ascii_uppercase, k=6))
+
+ # Disable the initial button
+ button.disabled = True
+ # Remove the cancel button if it exists
+ cancel_button = discord.utils.get(self.children, label='Cancel')
+ if cancel_button:
+ self.remove_item(cancel_button)
+
+ # Add the final confirmation button
+ confirm_button = discord.ui.Button(
+ label=f'CONFIRM WIPE - Type "{self.confirmation_phrase}"',
+ style=discord.ButtonStyle.danger,
+ disabled=True,
+ custom_id='final_confirm'
+ )
+ self.add_item(confirm_button)
+
+ await interaction.response.edit_message(
+ content=f"โ ๏ธ **FINAL WARNING**\n\n"
+ f"To proceed with wiping ALL city data for ALL users, you must type:\n"
+ f"```\n{self.confirmation_phrase}\n```\n"
+ f"This will permanently delete all user stats, crime records, and other city data.\n"
+ f"You have 30 seconds to confirm.",
+ view=self
+ )
+
+ # Start listening for the confirmation message
+ def check(m) -> bool:
+ return m.author.id == self.ctx.author.id and m.channel.id == self.ctx.channel.id and m.content.lower() == "confirm"
+
+ try:
+ await self.ctx.bot.wait_for('message', timeout=30.0, check=check)
+ self.value = True
+ except asyncio.TimeoutError:
+ self.value = None
+ finally:
+ self.stop()
+ # Try to update the message one last time
+ try:
+ for item in self.children:
+ item.disabled = True
+ await self.message.edit(view=self)
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+ @discord.ui.button(label='Cancel', style=discord.ButtonStyle.grey)
+ async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
+ if self.waiting_for_confirmation:
+ return
+
+ self.value = False
+ self.stop()
+ # Disable all buttons after clicking
+ for item in self.children:
+ item.disabled = True
+ await interaction.response.edit_message(view=self)
+
+ async def on_timeout(self) -> None:
+ # Disable all buttons if the view times out
+ for item in self.children:
+ item.disabled = True
+ # Try to update the message with disabled buttons
+ try:
+ await self.message.edit(view=self)
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+ @commands.command()
+ @commands.is_owner()
+ async def wipecitydata(self, ctx: commands.Context, user: discord.Member):
+ """Wipe all city-related data for a specific user.
+
+ This will remove all their stats, including:
+ - Crime records and cooldowns
+ - Jail status and history
+ - All statistics (successful crimes, failed crimes, etc.)
+ - All perks and items
+ - References in other users' data
+
+ This action cannot be undone.
+
+ Parameters
+ ----------
+ user: discord.Member
+ The user whose data should be wiped
+ """
+ # Create confirmation view
+ view = self.ConfirmWipeView(ctx, user)
+ view.message = await ctx.send(
+ f"โ ๏ธ Are you sure you want to wipe all city data for {user.display_name}?\n"
+ "This action cannot be undone and will remove all their stats, including:\n"
+ "โข Crime records and cooldowns\n"
+ "โข Jail status and history\n"
+ "โข All statistics (successful crimes, failed crimes, etc.)\n"
+ "โข All perks and items\n"
+ "โข References in other users' data",
+ view=view
+ )
+
+ # Wait for the user's response
+ await view.wait()
+
+ if view.value is None:
+ await ctx.send("โ Wipe cancelled - timed out.")
+ return
+ elif view.value is False:
+ await ctx.send("โ Wipe cancelled.")
+ return
+
+ try:
+ # Step 1: Clear all member data across all guilds
+ all_guilds = self.bot.guilds
+ for guild in all_guilds:
+ await self.config.member_from_ids(guild.id, user.id).clear()
+
+ # Step 2: Remove user from other members' data
+ all_members = await self.config.all_members()
+ for guild_id, guild_data in all_members.items():
+ for member_id, member_data in guild_data.items():
+ if member_id == user.id:
+ continue # Skip the user's own data as it's already cleared
+
+ modified = False
+ # Check last_target
+ if member_data.get("last_target") == user.id:
+ await self.config.member_from_ids(guild_id, member_id).last_target.set(None)
+ modified = True
+
+ await ctx.send(f"โ
Successfully wiped all city data for {user.display_name} across all guilds.")
+
+ except Exception as e:
+ await ctx.send(f"โ An error occurred while wiping data: {str(e)}")
+
+ @commands.command()
+ @commands.is_owner()
+ async def wipecityallusers(self, ctx: commands.Context):
+ """Wipe ALL city-related data for ALL users.
+
+ This is an extremely destructive action that will:
+ - Delete ALL user stats
+ - Remove ALL crime records and cooldowns
+ - Clear ALL jail status and history
+ - Wipe ALL perks and items
+ - Remove ALL cross-user references
+ - Remove ALL data across ALL guilds
+
+ This action absolutely cannot be undone.
+ """
+ # Create confirmation view
+ view = self.ConfirmGlobalWipeView(ctx)
+ view.message = await ctx.send(
+ "๐จ **GLOBAL CITY DATA WIPE** ๐จ\n\n"
+ "You are about to wipe ALL city data for ALL users across ALL guilds.\n\n"
+ "This will permanently delete:\n"
+ "โข All user statistics\n"
+ "โข All crime records and cooldowns\n"
+ "โข All jail records and history\n"
+ "โข All perks and items\n"
+ "โข All cross-user references\n"
+ "โข All other city-related data\n\n"
+ "This action CANNOT be undone and will affect ALL users.\n"
+ "Are you sure you want to proceed?",
+ view=view
+ )
+
+ # Wait for the user's response
+ await view.wait()
+
+ if view.value is None:
+ await ctx.send("โ Global wipe cancelled - timed out.")
+ return
+ elif view.value is False:
+ await ctx.send("โ Global wipe cancelled.")
+ return
+
+ try:
+ # Step 1: Clear all member data across all guilds
+ all_members = await self.config.all_members()
+ count = 0
+ for guild_id, guild_data in all_members.items():
+ for member_id in guild_data.keys():
+ await self.config.member_from_ids(guild_id, member_id).clear()
+ count += 1
+
+ # Step 2: Clear all guild settings to defaults
+ guild_count = 0
+ all_guilds = self.bot.guilds
+ for guild in all_guilds:
+ await self.config.guild(guild).clear()
+ guild_count += 1
+
+ await ctx.send(f"โ
Successfully wiped ALL city data:\n"
+ f"โข Cleared data for {count:,} users\n"
+ f"โข Reset settings in {guild_count:,} guilds")
+
+ except Exception as e:
+ await ctx.send(f"โ An error occurred while wiping data: {str(e)}")
+
+ @city.command(name="inventory")
+ @commands.guild_only()
+ async def city_inventory(self, ctx: commands.Context) -> None:
+ """View your inventory of items and perks from all city systems.
+
+ This command displays a combined view of all items and perks you own
+ from different city systems like crime, business, etc.
+ """
+ # Gather items from different systems
+ all_items: Dict[str, Dict[str, Any]] = {}
+
+ # Add crime items if crime system is loaded
+ try:
+ from .crime.blackmarket import BLACKMARKET_ITEMS
+ all_items.update(BLACKMARKET_ITEMS)
+ except ImportError:
+ pass
+
+ # Add business items if business system is loaded
+ try:
+ from .business.shop import BUSINESS_ITEMS
+ all_items.update(BUSINESS_ITEMS)
+ except ImportError:
+ pass
+
+ # Display combined inventory
+ from .inventory import display_inventory
+ await display_inventory(self, ctx, all_items)
diff --git a/city/business_system_design.md b/city/business_system_design.md
new file mode 100644
index 0000000..dcbb59c
--- /dev/null
+++ b/city/business_system_design.md
@@ -0,0 +1,165 @@
+# Business System - City Cog
+
+## Overview
+The business system is a dynamic economic feature that allows players to establish and manage their own businesses within the city. Players can earn passive income, upgrade their establishments, and engage in strategic risk management. The system incorporates elements of passive income generation, player interaction through robberies (via the crime module), and progressive advancement through multiple business levels and industries.
+
+Key aspects:
+- Passive income through a vault-based system
+- Three distinct industry types with unique mechanics
+- Upgradeable business levels with increasing benefits
+- Strategic security vs. profit decisions
+- Interactive robbery system for player engagement
+- Shop system with progressive unlocks
+
+## ๐ข Core Features
+
+### ๐ฐ Passive Income Generation
+- Deposit credits into your business vault
+- Earn daily profits based on vault balance
+- Business vault can be robbed by players, stealing a percentage of the vault balance
+- Strategic risk vs. reward management
+
+### ๐ฆ Vault System
+- Put credits in your vault to earn profit
+- Every hour you get 1/24th of your daily rate
+- Profits automatically add to your vault
+- More credits in vault = more profit per hour
+- Anyone can try to rob your vault
+- Vault has a maximum capacity based on level
+- Choose between collecting profits or letting them compound
+
+Example:
+- You have a Level 1 business (2% daily rate)
+- You put 10,000 credits in your vault
+- Each hour you earn: 10,000 ร (2% รท 24) = 8.33 credits
+- After one day: ~200 credits profit
+- Profits add to your vault automatically every hour
+
+### โฌ๏ธ Business Progression and Shop
+- Multiple upgrade levels with increased vault capacity
+- Better income generation rates at higher levels
+- Access to better shop items, including security upgrades
+- Level-gated special items
+- Mix of defensive and profit-focused items
+
+## Business Industries
+
+Choose your industry when starting (expensive to change later):
+
+### ๐น Trading
+- Daily profit rate fluctuates between -10% to +10% of base rate, +5% both ways for each level
+- Rate changes at midnight server time
+- 24h lockup period after deposits (no withdrawals)
+- Each day with increased profit adds +1% to base rate (max +10% bonus)
+- Bonus persists until a day with negative rate
+- Best for players who enjoy active management and timing
+- Example: At level 1 (2% base), rate varies from 1.7% to 2.3% daily
+
+### ๐ญ Manufacturing
+- Base profit rate: -1% from the base rate (1% profit rate at lv 1)
+- Vault capacity +25% (Level 1: 37.5k instead of 30k)
+- Robbery steal % reduced by 15% (steal 8.5-21.25% instead of 10-25%)
+- Consecutive days without withdrawal adds +0.5% profit (max +10%)
+- Best for players who prefer steady, long-term growth
+
+### ๐ฏ Retail
+- Base profit rate +35%
+- Robbery success rate increases by 15%
+- Each successful robbery reduces profit rate by 5% for 24hr
+- Robbery penalties stack up to -15% maximum
+- Best for players who prefer high risk, high reward
+
+## Business Levels
+
+### Level 1
+- Max vault: 30,000 credits
+- Income rate: 2% daily
+- Access to basic shop items
+
+### Level 2
+- Max vault: 75,000 credits
+- Income rate: 2.5% daily
+- Unlocks intermediate shop items
+
+### Level 3
+- Max vault: 150,000 credits
+- Income rate: 3% daily
+- Unlocks advanced shop items
+
+### Level 4
+- Max vault: 300,000 credits
+- Income rate: 3.5% daily
+- Unlocks premium shop items
+
+## Shop Items
+
+### Level 1 Items
+- ๐ท Security Camera (-10% robbery success) - 10,000 credits
+- ๐จ Basic Alarm (-10% robbery success) - 15,000 credits
+
+### Level 2 Unlocks
+- ๐ Security Guard (-15% robbery success) - 25,000 credits
+- ๐ฆ Vault Insurance (recover 25% of stolen credits) - 35,000 credits
+
+### Level 3 Unlocks
+- ๐ก๏ธ Advanced Security System (-20% robbery success) - 50,000 credits
+- ๐ Risk Management (reduce max steal % by 5) - 75,000 credits
+
+### Level 4 Unlocks
+- ๐ Premium Vault (25% of the vault is not considered for robbery percentage) - 100,000 credits
+- ๐ Market Analyzer (+10% profit rate) - 125,000 credits
+
+## Commands
+
+### Business Owner Commands
+- `[p]business` - Open the main business menu
+ - `start ` - Start a new business
+ - `deposit ` - Add credits to vault
+ - `withdraw ` - Remove credits from vault
+ - `upgrade` - Level up your business
+ - `shop` - View and purchase items
+ - `status` - View business stats and info, including profit generated in the last 24 hours
+ - `changeindustry ` - Change business industry (costs 50% of your current max vault capacity)
+
+### Criminal Commands
+- `[p]crime commit business ` - Rob a business
+ - Shows target's business level
+ - Displays visible security measures
+ - Indicates approximate vault balance
+ - Shows success chance and potential steal amount
+
+## System Mechanics
+
+### Starting Out
+- Players start a business with initial investment
+- Choose an industry type that fits their playstyle
+- Begin with basic shop items available
+
+### Income Generation
+- Larger vault balance = more daily profit
+- Profits automatically add to vault balance
+- All stored money (including profits) can be stolen
+- Must balance growth vs. risk
+
+### Business Robbery
+- Criminals can target a business vault once every 24 hours
+- Base steal range: 5-15% of vault balance
+- Vaults need a minimum of 2500 credits to be robbed
+- Success chance calculation:
+ - Start with 50% base chance
+ - Add/subtract industry modifiers
+ - Subtract target's security items
+ - Add criminal's success rate items
+- Steal percentage calculation:
+ - Start with random 5-15% base steal
+ - Subtract security item effects (Risk Management: -5%)
+ - Subtract industry effects (Manufacturing: -15%)
+ - Apply to vault balance
+- Example:
+ - Manufacturing business with Risk Management
+
+### Progression
+- Upgrade business level for better benefits
+- Purchase shop items for security and profit
+- Manage vault balance strategically
+- Change industry for 50% of max vault capacity (Level 1: 15k, Level 2: 37.5k, Level 3: 75k, Level 4: 150k)
\ No newline at end of file
diff --git a/city/crime/__init__.py b/city/crime/__init__.py
new file mode 100644
index 0000000..2eb9a60
--- /dev/null
+++ b/city/crime/__init__.py
@@ -0,0 +1,7 @@
+"""Crime module for the City cog."""
+
+from .commands import CrimeCommands
+from .data import CRIME_TYPES, DEFAULT_GUILD, DEFAULT_MEMBER
+from .views import CrimeView, BailView, TargetSelectionView
+
+__all__ = ["CrimeCommands", "CRIME_TYPES", "DEFAULT_GUILD", "DEFAULT_MEMBER", "CrimeView", "BailView", "TargetSelectionView"]
diff --git a/city/crime/blackmarket.py b/city/crime/blackmarket.py
new file mode 100644
index 0000000..2424f0f
--- /dev/null
+++ b/city/crime/blackmarket.py
@@ -0,0 +1,224 @@
+"""Blackmarket system for the City cog.
+
+This module provides a shop interface for purchasing special items and perks
+that can be used to gain advantages in the crime system.
+
+The blackmarket offers both permanent perks and consumable items that can
+affect various aspects of the crime system, such as jail time and notifications.
+"""
+
+from typing import Dict, Any, Optional
+import discord
+from redbot.core import bank, commands
+
+# Registry of available blackmarket items
+BLACKMARKET_ITEMS: Dict[str, Dict[str, Any]] = {
+ "notify_ping": {
+ "name": "Jail Release Notification",
+ "description": "Get notified when your jail sentence is over",
+ "cost": 10000,
+ "type": "perk",
+ "emoji": "๐",
+ "can_sell": True,
+ "toggleable": True
+ },
+ "jail_reducer": {
+ "name": "Reduced Sentence",
+ "description": "Permanently reduce jail time by 20%",
+ "cost": 20000,
+ "type": "perk",
+ "emoji": "โ๏ธ",
+ "can_sell": True,
+ "toggleable": False
+ },
+ "jail_pass": {
+ "name": "Get Out of Jail Free",
+ "description": "Instantly escape from jail",
+ "cost": 1000,
+ "type": "consumable",
+ "emoji": "๐",
+ "can_sell": True,
+ "uses": 1
+ }
+}
+
+class BlackmarketView(discord.ui.View):
+ """A view for displaying and purchasing items from the blackmarket.
+
+ This view provides a shop interface where users can view and purchase
+ various items and perks that affect the crime system.
+
+ Attributes:
+ ctx (commands.Context): The command context
+ cog (commands.Cog): The cog instance that created this view
+ message (Optional[discord.Message]): The message containing this view
+ """
+
+ def __init__(self, ctx: commands.Context, cog: commands.Cog) -> None:
+ super().__init__(timeout=180)
+ self.ctx = ctx
+ self.cog = cog
+ self.message: Optional[discord.Message] = None
+ self._update_options()
+
+ def _update_options(self) -> None:
+ """Update the select menu options based on available items."""
+ # Clear existing options
+ for child in self.children[:]:
+ self.remove_item(child)
+
+ # Create select menu
+ select = discord.ui.Select(
+ placeholder="Select an item to purchase",
+ options=[
+ discord.SelectOption(
+ label=item["name"],
+ value=item_id,
+ description=f"{item['description']} - {item['cost']:,} credits",
+ emoji=item["emoji"]
+ )
+ for item_id, item in BLACKMARKET_ITEMS.items()
+ ]
+ )
+ select.callback = self._handle_purchase
+ self.add_item(select)
+
+ async def _handle_purchase(self, interaction: discord.Interaction) -> None:
+ """Handle item purchase.
+
+ Args:
+ interaction (discord.Interaction): The interaction that triggered this callback
+ """
+ if interaction.user.id != self.ctx.author.id:
+ await interaction.response.send_message("This menu is not for you!", ephemeral=True)
+ return
+
+ try:
+ item_id = interaction.data["values"][0]
+ item = BLACKMARKET_ITEMS[item_id]
+ except (KeyError, AttributeError):
+ await interaction.response.send_message(
+ "โ This item is no longer available!",
+ ephemeral=True
+ )
+ return
+
+ # Check if user can afford
+ balance = await bank.get_balance(self.ctx.author)
+ if balance < item["cost"]:
+ currency_name = await bank.get_currency_name(self.ctx.guild)
+ await interaction.response.send_message(
+ f"โ You need {item['cost']:,} {currency_name} to buy this!",
+ ephemeral=True
+ )
+ return
+
+ # Handle purchase
+ async with self.cog.config.member(self.ctx.author).all() as member_data:
+ if item["type"] == "perk":
+ if item_id in member_data.get("purchased_perks", []):
+ await interaction.response.send_message(
+ "โ You already own this perk!",
+ ephemeral=True
+ )
+ return
+
+ if "purchased_perks" not in member_data:
+ member_data["purchased_perks"] = []
+ member_data["purchased_perks"].append(item_id)
+
+ # Special handling for notify_ping
+ if item_id == "notify_ping":
+ member_data["notify_unlocked"] = True
+ member_data["notify_on_release"] = True
+ else: # consumable
+ if "active_items" not in member_data:
+ member_data["active_items"] = {}
+
+ if item_id in member_data["active_items"]:
+ current_uses = member_data["active_items"][item_id].get("uses", 0)
+ if current_uses > 0:
+ await interaction.response.send_message(
+ "โ You already have this item with uses remaining!",
+ ephemeral=True
+ )
+ return
+
+ member_data["active_items"][item_id] = {"uses": item["uses"]}
+
+ # Complete purchase
+ await bank.withdraw_credits(self.ctx.author, item["cost"])
+ currency_name = await bank.get_currency_name(self.ctx.guild)
+
+ await interaction.response.send_message(
+ f"โ
Purchased {item['emoji']} **{item['name']}** for {item['cost']:,} {currency_name}",
+ ephemeral=True
+ )
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ """Check if the interaction is valid.
+
+ Args:
+ interaction (discord.Interaction): The interaction to check
+
+ Returns:
+ bool: True if the interaction is valid, False otherwise
+ """
+ return interaction.user.id == self.ctx.author.id
+
+ async def on_timeout(self) -> None:
+ """Handle view timeout by disabling all components."""
+ try:
+ for child in self.children:
+ child.disabled = True
+ await self.message.edit(view=self)
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+async def display_blackmarket(cog: commands.Cog, ctx: commands.Context) -> None:
+ """Display the blackmarket shop interface.
+
+ Args:
+ cog (commands.Cog): The cog instance
+ ctx (commands.Context): The command context
+ """
+ currency_name = await bank.get_currency_name(ctx.guild)
+
+ # Create embed
+ embed = discord.Embed(
+ title="๐ดโโ ๏ธ Black Market",
+ description="Welcome to the black market! Here you can purchase special items and perks.",
+ color=discord.Color.dark_red()
+ )
+
+ # Add perks section
+ perks = []
+ for item_id, item in BLACKMARKET_ITEMS.items():
+ if item["type"] == "perk":
+ perks.append(f"{item['emoji']} **{item['name']}** - {item['cost']:,} {currency_name}\n"
+ f"โณ {item['description']}")
+
+ if perks:
+ embed.add_field(
+ name="__๐ Permanent Perks__",
+ value="\n".join(perks),
+ inline=False
+ )
+
+ # Add consumable items section
+ items = []
+ for item_id, item in BLACKMARKET_ITEMS.items():
+ if item["type"] == "consumable":
+ items.append(f"{item['emoji']} **{item['name']}** - {item['cost']:,} {currency_name}\n"
+ f"โณ {item['description']}")
+
+ if items:
+ embed.add_field(
+ name="__๐ฆ Consumable Items__",
+ value="\n".join(items),
+ inline=False
+ )
+
+ # Create and send view
+ view = BlackmarketView(ctx, cog)
+ view.message = await ctx.send(embed=embed, view=view)
\ No newline at end of file
diff --git a/city/crime/commands.py b/city/crime/commands.py
new file mode 100644
index 0000000..246b656
--- /dev/null
+++ b/city/crime/commands.py
@@ -0,0 +1,1238 @@
+"""Commands for the crime system."""
+
+from redbot.core import commands, bank
+from redbot.core.utils.chat_formatting import box, humanize_number
+from redbot.core.i18n import Translator
+import discord
+import random
+import time
+import asyncio
+from .scenarios import get_random_scenario, get_random_jailbreak_scenario
+from .views import CrimeListView, BailView, CrimeView, TargetSelectionView, CrimeButton, MainMenuView, AddScenarioModal
+from .data import CRIME_TYPES, DEFAULT_GUILD, DEFAULT_MEMBER
+from datetime import datetime
+from ..utils import (
+ format_cooldown_time,
+ get_crime_emoji,
+ format_crime_description,
+ format_streak_text
+)
+
+_ = Translator("Crime", __file__)
+
+class CrimeCommands:
+ """Crime commands mixin."""
+
+ def __init__(self, bot):
+ self.bot = bot
+ self.notification_tasks = {} # Store notification tasks by member ID
+
+ @commands.group(name="crime", invoke_without_command=True)
+ async def crime(self, ctx: commands.Context):
+ """Access the crime system.
+
+ If no subcommand is provided, shows the main menu with all available actions.
+ """
+ try:
+ # Get member data
+ member_data = await self.config.member(ctx.author).all()
+
+ # Get jail status
+ jail_remaining = await self.get_jail_time_remaining(ctx.author)
+ status = "โ๏ธ In jail" if jail_remaining > 0 else "โ
Free"
+
+ # Get streak info
+ streak = member_data.get("current_streak", 0)
+ streak_multiplier = member_data.get("streak_multiplier", 1.0)
+ streak_text = format_streak_text(streak, streak_multiplier)
+
+ # Create embed
+ embed = discord.Embed(
+ title="๐ Welcome to the Criminal Underworld",
+ description=(
+ "The city never sleeps, and neither do its criminals. What kind of trouble are you looking to get into today?\n\n"
+ "Choose your next move wisely..."
+ ),
+ color=discord.Color.dark_red()
+ )
+
+ # Add criminal record field
+ embed.add_field(
+ name="__Your Criminal Record__",
+ value=(
+ f"๐ฆน Current Status: {status}\n"
+ f"๐ฐ Lifetime Earnings: {humanize_number(member_data['total_credits_earned'])} {await bank.get_currency_name(ctx.guild)}\n"
+ f"โ
Successful Crimes: {member_data['total_successful_crimes']}\n"
+ f"โ Failed Attempts: {member_data['total_failed_crimes']}\n"
+ f"๐ Largest Heist: {humanize_number(member_data['largest_heist'])} {await bank.get_currency_name(ctx.guild)}\n"
+ f"๐ Current Streak: {streak_text}"
+ ),
+ inline=False
+ )
+
+ # Create and send view
+ view = MainMenuView(self, ctx)
+ view.message = await ctx.send(embed=embed, view=view)
+
+ # Initialize menu options
+ await view.initialize_menu()
+
+ except Exception as e:
+ await ctx.send(f"An error occurred while opening the crime menu: {str(e)}")
+
+ @crime.command(name="commit")
+ async def crime_commit(self, ctx: commands.Context):
+ """Choose a crime to commit using an interactive menu
+
+ Available crimes:
+ โข ๐งค Pickpocket: Low risk, target users for small rewards
+ โข ๐ช Mugging: Medium risk, target users for medium rewards
+ โข ๐ช Store Robbery: Medium risk, no target needed
+ โข ๐ Bank Heist: High risk, high rewards
+ โข ๐ฒ Random Crime: Random risk and rewards
+
+ Each crime has:
+ โข Color-coded risk levels (green=low, blue=medium, red=high)
+ โข Success rates shown before committing
+ โข Cooldown periods between attempts
+ โข Fines and jail time if caught
+
+ Getting caught will send you to jail!
+ """
+ try:
+ # Get guild settings
+ settings = await self.config.guild(ctx.guild).global_settings()
+ crime_options = await self.config.guild(ctx.guild).crime_options()
+
+ # Check jail status
+ jail_remaining = await self.get_jail_time_remaining(ctx.author)
+ jail_status = ""
+ if jail_remaining > 0:
+ if jail_remaining > 3600: # More than 1 hour
+ hours = jail_remaining // 3600
+ minutes = (jail_remaining % 3600) // 60
+ jail_status = f"โ๏ธ **JAILED** for {hours}h {minutes}m"
+ else:
+ minutes = jail_remaining // 60
+ seconds = jail_remaining % 60
+ jail_status = f"โ๏ธ **JAILED** for {minutes}m {seconds}s"
+
+ # Create embed with crime options
+ embed = discord.Embed(
+ title="๐ฆนโโ๏ธ Criminal Activities",
+ description=_(
+ "Choose your next heist wisely...\n"
+ "{jail_status}\n"
+ "**Fines:**\n"
+ "๐ข Low Risk: 30-35% of max reward\n"
+ "๐ก Medium Risk: 40-45% of max reward\n"
+ "๐ด High Risk: 45-50% of max reward\n\n"
+ ).format(
+ jail_status=jail_status + "\n" if jail_status else ""
+ ),
+ color=await ctx.embed_color()
+ )
+
+ # Add crime options to embed
+ crimes = list(crime_options.items())
+ for i in range(0, len(crimes), 2):
+ # Get current crime
+ crime_type, data = crimes[i]
+
+ # Get cooldown status
+ remaining = await self.get_remaining_cooldown(ctx.author, crime_type)
+ status = format_cooldown_time(remaining)
+
+ # Format description
+ description = format_crime_description(crime_type, data, status)
+
+ # Check if there's a next crime to pair with
+ if i + 1 < len(crimes):
+ # Get next crime
+ next_crime_type, next_data = crimes[i + 1]
+
+ # Get cooldown status for next crime
+ next_remaining = await self.get_remaining_cooldown(ctx.author, next_crime_type)
+ next_status = format_cooldown_time(next_remaining)
+
+ # Format next description
+ next_description = format_crime_description(next_crime_type, next_data, next_status)
+
+ # Add both crimes side by side
+ embed.add_field(
+ name=f"{get_crime_emoji(crime_type)} __**{crime_type.replace('_', ' ').title()}**__",
+ value=description,
+ inline=True
+ )
+ embed.add_field(
+ name=f"{get_crime_emoji(next_crime_type)} __**{next_crime_type.replace('_', ' ').title()}**__",
+ value=next_description,
+ inline=True
+ )
+ # Add empty field to force next pair to start on new line
+ embed.add_field(name="\u200b", value="\u200b", inline=True)
+ else:
+ # Add single crime if no pair
+ embed.add_field(
+ name=f"{get_crime_emoji(crime_type)} __**{crime_type.replace('_', ' ').title()}**__",
+ value=description,
+ inline=True
+ )
+
+ embed.set_footer(text="Use the buttons below to choose a crime")
+
+ # Create view with crime buttons
+ view = CrimeListView(self, ctx, crime_options)
+ message = await ctx.send(embed=embed, view=view)
+ view.message = message
+
+ # Update button states based on jail and cooldowns
+ await view.update_button_states()
+
+ except Exception as e:
+ await ctx.send(_("An error occurred while setting up the crime options. Please try again. Error: {}").format(str(e)))
+
+ @crime.command(name="status")
+ async def crime_status(self, ctx: commands.Context, user: discord.Member = None):
+ """View current jail status, cooldowns, and other active states
+
+ Parameters
+ ----------
+ user : discord.Member, optional
+ The user to check status for. If not provided, shows your own status."""
+ try:
+ # Get member data
+ target = user or ctx.author
+ member_data = await self.config.member(target).all()
+ settings = await self.config.guild(ctx.guild).global_settings()
+ crime_options = await self.config.guild(ctx.guild).crime_options()
+
+ # Create status embed
+ embed = discord.Embed(
+ title="๐ฆนโโ๏ธ Criminal Status",
+ description=f"Current status for {target.mention}",
+ color=await ctx.embed_color()
+ )
+
+ # Set thumbnail to user's avatar
+ embed.set_thumbnail(url=target.display_avatar.url)
+
+ # Check jail status
+ remaining_jail = await self.get_jail_time_remaining(target)
+ if remaining_jail > 0:
+ # Check if user has reduced sentence perk
+ has_reducer = "jail_reducer" in member_data.get("purchased_perks", [])
+
+ if has_reducer:
+ # Calculate original time (current time is after 20% reduction)
+ original_time = int(remaining_jail / 0.8) # Reverse the 20% reduction
+ jail_text = f"๐ In jail for ~~{format_cooldown_time(original_time, include_emoji=False)}~~ โ {format_cooldown_time(remaining_jail, include_emoji=False)} (-20%)"
+ else:
+ jail_text = f"๐ In jail for {format_cooldown_time(remaining_jail)}"
+
+ embed.add_field(
+ name="โ๏ธ __Jail Status__",
+ value=jail_text,
+ inline=False
+ )
+
+ # Show if they've attempted jailbreak this sentence
+ if member_data.get("attempted_jailbreak", False):
+ embed.add_field(
+ name="๐ __Jailbreak Status__",
+ value="โ Already attempted this sentence",
+ inline=False
+ )
+ else:
+ embed.add_field(
+ name="โ๏ธ __Jail Status__",
+ value="๐ Not in jail",
+ inline=False
+ )
+
+ # Add crime cooldowns in a more compact format
+ cooldowns = []
+ for crime_type, data in crime_options.items():
+ if not data.get("enabled", True):
+ continue
+
+ remaining = await self.get_remaining_cooldown(target, crime_type)
+ crime_name = crime_type.replace('_', ' ').title()
+ cooldowns.append(
+ f"{get_crime_emoji(crime_type)} **{crime_name}:** {format_cooldown_time(remaining)}"
+ )
+
+ if cooldowns:
+ # Split cooldowns into two columns
+ mid = len(cooldowns) // 2 + len(cooldowns) % 2
+
+ embed.add_field(
+ name="๐
__Crime Cooldowns__",
+ value="\n".join(cooldowns[:mid]),
+ inline=True
+ )
+
+ if len(cooldowns) > mid:
+ embed.add_field(
+ name="\u200b",
+ value="\n".join(cooldowns[mid:]),
+ inline=True
+ )
+
+ # Add empty field to maintain grid layout
+ embed.add_field(name="\u200b", value="\u200b", inline=True)
+
+ # Add notification status
+ notify_on_release = member_data.get("notify_on_release", False)
+ notify_unlocked = member_data.get("notify_unlocked", False)
+ has_reducer = "jail_reducer" in member_data.get("purchased_perks", [])
+
+ if notify_unlocked or has_reducer:
+ status_lines = []
+ if notify_unlocked:
+ status_lines.append("๐ Notifications " + ("enabled" if notify_on_release else "disabled"))
+ if has_reducer:
+ status_lines.append("โ๏ธ Reduced Sentence (-20% jail time)")
+
+ embed.add_field(
+ name="๐ฐ __Active Perks__",
+ value="\n".join(status_lines),
+ inline=True
+ )
+
+ # Add last target if any
+ if member_data['last_target']:
+ try:
+ last_target = await ctx.guild.fetch_member(member_data['last_target'])
+ if last_target:
+ embed.add_field(
+ name="๐ฏ __Last Target__",
+ value=last_target.mention,
+ inline=True
+ )
+ except discord.NotFound:
+ pass
+
+ await ctx.send(embed=embed)
+
+ except Exception as e:
+ await ctx.send(_("An error occurred while retrieving the status. Please try again. Error: {}").format(str(e)))
+
+ @crime.command(name="stats")
+ async def crime_stats(self, ctx: commands.Context, user: discord.Member = None):
+ """View detailed crime statistics and financial impact
+
+ Parameters
+ ----------
+ user : discord.Member, optional
+ The user to check stats for. If not provided, shows your own stats."""
+ try:
+ # Get member data
+ target = user or ctx.author
+ member_data = await self.config.member(target).all()
+ currency_name = await bank.get_currency_name(ctx.guild)
+
+ # Create stats embed
+ embed = discord.Embed(
+ title="๐ Criminal Statistics",
+ description=f"Detailed statistics for {target.mention}",
+ color=await ctx.embed_color()
+ )
+
+ # Set thumbnail to user's avatar
+ embed.set_thumbnail(url=target.display_avatar.url)
+
+ # Add crime statistics in two columns
+ stats_left = [
+ f"**{currency_name} Earned:** {humanize_number(member_data['total_credits_earned'])}",
+ f"**Crimes:** โ
{member_data['total_successful_crimes']} | โ {member_data['total_failed_crimes']}",
+ f"**{currency_name} Stolen:** {humanize_number(member_data['total_stolen_from'])}",
+ f"**Largest Heist:** {humanize_number(member_data['largest_heist'])}",
+ f"**Highest Streak:** ๐ฅ {member_data.get('highest_streak', 0)}"
+ ]
+
+ # Calculate success rate
+ total_crimes = member_data['total_successful_crimes'] + member_data['total_failed_crimes']
+ success_rate = (member_data['total_successful_crimes'] / total_crimes * 100) if total_crimes > 0 else 0
+
+ stats_right = [
+ f"**Total Fines:** {humanize_number(member_data['total_fines_paid'])}",
+ f"**Total Bail:** {humanize_number(member_data['total_bail_paid'])}",
+ f"**{currency_name} Lost:** {humanize_number(member_data['total_stolen_by'])}",
+ f"**Success Rate:** {success_rate:.1f}%" if total_crimes > 0 else "**Success Rate:** N/A"
+ ]
+
+ embed.add_field(
+ name="๐ __Crime Statistics__",
+ value="\n".join(stats_left),
+ inline=True
+ )
+
+ embed.add_field(
+ name="๐ฐ __Financial Impact__",
+ value="\n".join(stats_right),
+ inline=True
+ )
+
+ await ctx.send(embed=embed)
+
+ except Exception as e:
+ await ctx.send(_("An error occurred while retrieving the stats. Please try again. Error: {}").format(str(e)))
+
+ @crime.command(name="bail")
+ async def crime_bail(self, ctx: commands.Context):
+ """Pay bail to get out of jail early
+
+ Bail cost increases with remaining jail time.
+ Cost is calculated as: remaining_minutes * base_bail_rate
+ """
+ try:
+ # Check if user is in jail
+ jail_time = await self.get_jail_time_remaining(ctx.author)
+ if jail_time <= 0:
+ await ctx.send(_("You're not in jail!"))
+ return
+
+ # Get settings
+ settings = await self.config.guild(ctx.guild).global_settings()
+ if not settings.get("allow_bail", True):
+ await ctx.send(_("Bail is not allowed in this server!"))
+ return
+
+ # Calculate bail cost based on remaining time
+ bail_cost = int(settings.get("bail_cost_multiplier", 1.5) * (jail_time / 60)) # Convert seconds to minutes
+
+ # Check if user can afford bail
+ if not await bank.can_spend(ctx.author, bail_cost):
+ await ctx.send(
+ _("๐ตโYou don't have enough {currency} to pay the bail amount of {amount}!").format(
+ currency=await bank.get_currency_name(ctx.guild),
+ amount=bail_cost
+ )
+ )
+ return
+
+ # Get current balance
+ current_balance = await bank.get_balance(ctx.author)
+ currency_name = await bank.get_currency_name(ctx.guild)
+
+ # Get member data for perk check
+ member_data = await self.config.member(ctx.author).all()
+
+ # Create embed for bail prompt
+ embed = discord.Embed(
+ title="๐ฐ Bail Payment Available",
+ description=(
+ "You can pay bail to get out of jail immediately, or wait out your sentence.\n\n"
+ f"**Time Remaining:** {format_cooldown_time(jail_time, include_emoji=False)}"
+ + (" (Reduced by 20%)" if "jail_reducer" in member_data.get("purchased_perks", []) else "") + "\n"
+ f"**Bail Cost:** {bail_cost:,} {currency_name}\n"
+ f"**Current Balance:** {current_balance:,} {currency_name}\n\n"
+ ),
+ color=discord.Color.gold(),
+ timestamp=discord.utils.utcnow()
+ )
+ embed.set_footer(text=f"Requested by {ctx.author.display_name}", icon_url=ctx.author.display_avatar.url)
+
+ # Send bail prompt
+ view = BailView(self, ctx, bail_cost, jail_time)
+ message = await ctx.send(embed=embed, view=view)
+ view.message = message
+
+ # Reset attempted jailbreak flag when bailing out
+ async with self.config.member(ctx.author).all() as member_data:
+ member_data["attempted_jailbreak"] = False
+
+ except Exception as e:
+ await ctx.send(_("An error occurred while processing your bail request. Please try again. Error: {}").format(str(e)))
+
+ @crime.command(name="jailbreak")
+ async def crime_jailbreak(self, ctx: commands.Context):
+ """Attempt to break out of jail
+
+ Failed attempt increases jail time by 30%!
+ """
+ try:
+ # Check if user is in jail
+ jail_time = await self.get_jail_time_remaining(ctx.author)
+ if jail_time <= 0:
+ await ctx.send(_("You're not in jail!"))
+ return
+
+ # Get member data
+ member_data = await self.config.member(ctx.author).all()
+
+ # Check if already attempted jailbreak this sentence
+ if member_data.get("attempted_jailbreak", False):
+ await ctx.send(_("You've already attempted to break out this sentence!"))
+ return
+
+ # Mark jailbreak as attempted
+ async with self.config.member(ctx.author).all() as member_data:
+ member_data["attempted_jailbreak"] = True
+
+ # Get random scenario
+ scenario = get_random_jailbreak_scenario()
+ success_chance = scenario["base_chance"]
+
+ # Send attempt message
+ attempt_msg = await ctx.send(_(scenario['attempt_text']).format(
+ user=ctx.author.mention
+ ))
+
+ # Add suspense delay
+ await asyncio.sleep(3)
+
+ # Get 1-3 random events
+ num_events = random.randint(1, 3)
+ selected_events = random.sample(scenario['events'], num_events)
+
+ # Apply events in sequence
+ for event in selected_events:
+ event_text = event['text']
+ # Format event text with currency name
+ currency_name = await bank.get_currency_name(ctx.guild)
+ event_text = event_text.format(currency=currency_name)
+
+ # Apply chance modifiers and log them
+ if "chance_bonus" in event:
+ success_chance = min(1.0, success_chance + event["chance_bonus"]) # Cap at 100%
+ event_text = f"{event_text}"
+ elif "chance_penalty" in event:
+ success_chance = max(0.05, success_chance - event["chance_penalty"]) # Minimum 5% chance
+ event_text = f"{event_text}"
+
+ # Apply currency modifiers
+ if "currency_bonus" in event:
+ await bank.deposit_credits(ctx.author, event["currency_bonus"])
+ event_text += f" (+{event['currency_bonus']} {currency_name})"
+ elif "currency_penalty" in event:
+ penalty_amount = event["currency_penalty"]
+ # Check if user can afford the penalty before withdrawing
+ if await bank.can_spend(ctx.author, penalty_amount):
+ await bank.withdraw_credits(ctx.author, penalty_amount)
+ event_text += f" (-{penalty_amount} {currency_name})"
+ # else: Optionally add a message here if they couldn't afford it, or just skip silently
+
+ await ctx.send(event_text)
+ await asyncio.sleep(3.5)
+
+ roll = random.random()
+
+ # Attempt escape
+ if roll < success_chance:
+ # Success! Clear jail time
+ await self.config.member(ctx.author).jail_until.set(0)
+ await self.config.member(ctx.author).attempted_jailbreak.set(False) # Reset jailbreak attempt when successful
+
+ # Cancel any pending release notification
+ await self._cancel_notification(ctx.author)
+
+ # Double check jail time is cleared
+ remaining = await self.get_jail_time_remaining(ctx.author)
+ if remaining > 0:
+ await ctx.send(_("Jail time not properly cleared! Remaining: {}").format(format_cooldown_time(remaining)))
+ # Force clear it
+ await self.config.member(ctx.author).jail_until.set(0)
+
+ # Create success embed
+ embed = discord.Embed(
+ title="๐ Successful Jailbreak!",
+ description=_(scenario['success_text']).format(user=ctx.author.mention),
+ color=discord.Color.green()
+ )
+ embed.add_field(
+ name="๐ฒ Final Escape Chance",
+ value=f"{success_chance:.1%}",
+ inline=True
+ )
+ await ctx.send(embed=embed)
+ else:
+ # Failed - add 30% more jail time
+ remaining_time = await self.get_jail_time_remaining(ctx.author)
+ added_time = int(remaining_time * 0.3) # Add 30% more time
+
+ # Get current jail end time and add the additional time
+ current_jail_until = await self.config.member(ctx.author).jail_until()
+ new_jail_until = current_jail_until + added_time
+ await self.config.member(ctx.author).jail_until.set(new_jail_until)
+
+ # Create fail embed
+ embed = discord.Embed(
+ title="โ๏ธ Failed Jailbreak!",
+ description=_(scenario['fail_text']).format(user=ctx.author.mention),
+ color=discord.Color.red()
+ )
+
+ # Format the time for penalty calculation
+ minutes = remaining_time // 60
+ seconds = remaining_time % 60
+ new_minutes = int((remaining_time * 1.3) // 60)
+ new_seconds = int((remaining_time * 1.3) % 60)
+
+ embed.add_field(
+ name="โ๏ธ Penalty",
+ value=f"Your sentence has been increased by 30%!\n ({minutes}m {seconds}s + 30% = โฐ {new_minutes}m {new_seconds}s)",
+ inline=True
+ )
+ embed.add_field(
+ name="๐ฒ Final Escape Chance",
+ value=f"{success_chance:.1%}",
+ inline=True
+ )
+ await ctx.send(embed=embed)
+
+ except Exception as e:
+ await ctx.send(_("An error occurred while processing your jailbreak attempt. Please try again. Error: {}").format(str(e)))
+
+ @crime.command(name="leaderboard", aliases=["lb"])
+ @commands.guild_only()
+ async def crime_leaderboard(self, ctx: commands.Context):
+ """View the server's crime leaderboard."""
+
+ # Get guild's currency name
+ currency_name = await bank.get_currency_name(ctx.guild)
+
+ stats = {
+ "earnings": {
+ "title": f"๐ฐ __Most {currency_name} Earned__",
+ "field": "total_credits_earned",
+ "format": "credits"
+ },
+ "crimes": {
+ "title": "๐ฆน __Crime Success/Fails__",
+ "fields": ["total_successful_crimes", "total_failed_crimes"],
+ "format": "counts"
+ },
+ "stolen": {
+ "title": f"๐ __Stolen/Lost {currency_name}__",
+ "fields": ["total_stolen_from", "total_stolen_by"],
+ "format": "credits"
+ },
+ "largest_heist": {
+ "title": f"๐ __Largest Heist__",
+ "field": "largest_heist",
+ "format": "credits"
+ },
+ "fines": {
+ "title": f"๐ธ __Most Fines/Bail Paid__",
+ "fields": ["total_fines_paid", "total_bail_paid"],
+ "format": "credits"
+ },
+ "streaks": {
+ "title": "๐ฅ __Highest Crime Streak__",
+ "field": "highest_streak",
+ "format": "number"
+ }
+ }
+
+ # Get all member data
+ all_members = await self.config.all_members(ctx.guild)
+ if not all_members:
+ return await ctx.send("No crime statistics found for this server!")
+
+ embed = discord.Embed(
+ title="๐ Crime Leaderboard - Hall of Infamy",
+ description="The most notorious criminals in the server",
+ color=await ctx.embed_color()
+ )
+
+ # Medal emojis for top 3
+ medals = ["๐ฅ", "๐ฅ", "๐ฅ"]
+
+ # Process each stat category
+ field_count = 0 # Track number of non-empty fields
+ for stat_info in stats.values():
+ if "fields" in stat_info: # Combined stats
+ # Sort members by the sum of both fields
+ sorted_members = sorted(
+ all_members.items(),
+ key=lambda x: sum(x[1].get(field, 0) for field in stat_info["fields"]),
+ reverse=True
+ )[:3]
+
+ if not sorted_members:
+ continue
+
+ field_lines = []
+ for i, (member_id, data) in enumerate(sorted_members):
+ member = ctx.guild.get_member(member_id)
+ if member is None:
+ continue
+
+ if stat_info["format"] == "credits":
+ if stat_info["title"].startswith("๐ธ"): # Fines category
+ total_paid = data.get(stat_info['fields'][0], 0) + data.get(stat_info['fields'][1], 0)
+ field_lines.append(f"{medals[i]} **{member.display_name}** โข {humanize_number(total_paid)} {currency_name}")
+ elif stat_info["title"].startswith("๐"): # Stolen/Lost category
+ value1 = humanize_number(data.get(stat_info['fields'][0], 0))
+ value2 = humanize_number(data.get(stat_info['fields'][1], 0))
+ field_lines.append(f"{medals[i]} **{member.display_name}** โข {value1} / {value2}")
+ else:
+ value1 = f"{humanize_number(data.get(stat_info['fields'][0], 0))} {currency_name}"
+ value2 = f"{humanize_number(data.get(stat_info['fields'][1], 0))} {currency_name}"
+ field_lines.append(f"{medals[i]} **{member.display_name}** โข {value1} / {value2}")
+ else: # counts
+ wins = data.get(stat_info['fields'][0], 0)
+ fails = data.get(stat_info['fields'][1], 0)
+ field_lines.append(f"{medals[i]} **{member.display_name}** โข {wins}w / {fails}f")
+
+ if field_lines:
+ embed.add_field(
+ name=stat_info["title"],
+ value="\n".join(field_lines),
+ inline=True
+ )
+ field_count += 1
+
+ # Add empty field only if we have an odd number of fields and it's not the last field
+ if field_count % 2 == 1 and field_count < len(stats):
+ embed.add_field(name="\u200b", value="\u200b", inline=True)
+ else: # Single stat
+ sorted_members = sorted(
+ all_members.items(),
+ key=lambda x: x[1].get(stat_info["field"], 0),
+ reverse=True
+ )[:3]
+
+ if not sorted_members:
+ continue
+
+ field_lines = []
+ for i, (member_id, data) in enumerate(sorted_members):
+ member = ctx.guild.get_member(member_id)
+ if member is None:
+ continue
+
+ value = data.get(stat_info["field"], 0)
+ if stat_info["format"] == "credits":
+ value_str = f"{humanize_number(value)} {currency_name}"
+ else:
+ value_str = str(value)
+
+ field_lines.append(f"{medals[i]} **{member.display_name}** โข {value_str}")
+
+ if field_lines:
+ embed.add_field(
+ name=stat_info["title"],
+ value="\n".join(field_lines),
+ inline=True
+ )
+ field_count += 1
+
+ # Add empty field only if we have an odd number of fields and it's not the last field
+ if field_count % 2 == 1 and field_count < len(stats):
+ embed.add_field(name="\u200b", value="\u200b", inline=True)
+
+ # Add footer with timestamp
+ embed.set_footer(text=f"Updated")
+ embed.timestamp = datetime.now()
+
+ await ctx.send(embed=embed)
+
+ @commands.group(name="crimeset")
+ @commands.admin_or_permissions(administrator=True)
+ async def crime_set(self, ctx: commands.Context):
+ """Configure crime settings
+
+ Commands:
+ - success_rate: Set success rate for a crime (0.0 to 1.0)
+ - reward: Set min/max reward for a crime
+ - cooldown: Set cooldown duration in seconds
+ - jailtime: Set jail time duration in seconds
+ - fine: Set fine multiplier for a crime
+ - global: Configure global settings
+ """
+ pass
+
+ @crime_set.command(name="success_rate")
+ async def set_success_rate(
+ self, ctx: commands.Context, crime_type: str, rate: float
+ ):
+ """Set the success rate for a crime type (0.0 to 1.0)"""
+ if rate < 0 or rate > 1:
+ await ctx.send(_("Success rate must be between 0.0 and 1.0"))
+ return
+
+ async with self.config.guild(ctx.guild).crime_options() as crime_options:
+ if crime_type not in crime_options:
+ await ctx.send(_("Invalid crime type!"))
+ return
+
+ crime_options[crime_type]["success_rate"] = rate
+
+ await ctx.send(_("Success rate for {crime_type} set to {rate}").format(
+ crime_type=crime_type,
+ rate=rate
+ ))
+
+ @crime_set.command(name="reward")
+ async def set_reward(
+ self, ctx: commands.Context, crime_type: str, min_reward: int, max_reward: int
+ ):
+ """Set the reward range for a crime type"""
+ if min_reward < 0 or max_reward < min_reward:
+ await ctx.send(_("Invalid reward range!"))
+ return
+
+ async with self.config.guild(ctx.guild).crime_options() as crime_options:
+ if crime_type not in crime_options:
+ await ctx.send(_("Invalid crime type!"))
+ return
+
+ crime_options[crime_type]["min_reward"] = min_reward
+ crime_options[crime_type]["max_reward"] = max_reward
+
+ await ctx.send(_("Reward range for {crime_type} set to {min_reward}-{max_reward}").format(
+ crime_type=crime_type,
+ min_reward=min_reward,
+ max_reward=max_reward
+ ))
+
+ @crime_set.command(name="cooldown")
+ async def set_cooldown(
+ self, ctx: commands.Context, crime_type: str, cooldown: int
+ ):
+ """Set the cooldown for a crime type (in seconds)"""
+ if cooldown < 0:
+ await ctx.send(_("Cooldown must be positive!"))
+ return
+
+ async with self.config.guild(ctx.guild).crime_options() as crime_options:
+ if crime_type not in crime_options:
+ await ctx.send(_("Invalid crime type!"))
+ return
+
+ crime_options[crime_type]["cooldown"] = cooldown
+
+ await ctx.send(_("Cooldown for {crime_type} set to {time_remaining}").format(
+ crime_type=crime_type,
+ time_remaining=format_cooldown_time(cooldown)
+ ))
+
+ @crime_set.command(name="jailtime")
+ async def set_jail_time(
+ self, ctx: commands.Context, crime_type: str, jail_time: int
+ ):
+ """Set the jail time for a crime type (in seconds)"""
+ if jail_time < 0:
+ await ctx.send(_("Jail time must be positive!"))
+ return
+
+ async with self.config.guild(ctx.guild).crime_options() as crime_options:
+ if crime_type not in crime_options:
+ await ctx.send(_("Invalid crime type!"))
+ return
+
+ crime_options[crime_type]["jail_time"] = jail_time
+
+ await ctx.send(_("Jail time for {crime_type} set to {time_remaining}").format(
+ crime_type=crime_type,
+ time_remaining=format_cooldown_time(jail_time)
+ ))
+
+ @crime_set.command(name="fine")
+ async def set_fine_multiplier(
+ self, ctx: commands.Context, crime_type: str, multiplier: float
+ ):
+ """Set the fine multiplier for a crime type"""
+ if multiplier < 0:
+ await ctx.send(_("Fine multiplier must be positive!"))
+ return
+
+ async with self.config.guild(ctx.guild).crime_options() as crime_options:
+ if crime_type not in crime_options:
+ await ctx.send(_("Invalid crime type!"))
+ return
+
+ crime_options[crime_type]["fine_multiplier"] = multiplier
+
+ await ctx.send(_("Fine multiplier for {crime_type} set to {multiplier}").format(
+ crime_type=crime_type,
+ multiplier=multiplier
+ ))
+
+ @crime_set.command(name="reload_defaults")
+ @commands.admin_or_permissions(administrator=True)
+ async def reload_crime_defaults(self, ctx: commands.Context):
+ """Reload the default crime settings for this guild.
+
+ This will update all crime options to match the defaults in data.py.
+ Warning: This will overwrite any custom settings!
+ """
+ from .data import CRIME_TYPES
+
+ # Update crime options with defaults
+ await self.config.guild(ctx.guild).crime_options.set(CRIME_TYPES.copy())
+
+ await ctx.send("โ
Crime settings have been reloaded from defaults!")
+
+ @crime_set.group(name="global")
+ async def crime_set_global(self, ctx: commands.Context):
+ """Configure global crime settings
+
+ Commands:
+ - bailcost: Set bail cost multiplier
+ - togglebail: Enable/disable the bail system
+ - view: View all current settings
+ """
+ pass
+
+ @crime_set_global.command(name="bailcost")
+ async def set_bail_multiplier(
+ self, ctx: commands.Context, multiplier: float
+ ):
+ """Set the bail cost multiplier"""
+ if multiplier < 0:
+ await ctx.send(_("Bail cost multiplier must be positive!"))
+ return
+
+ async with self.config.guild(ctx.guild).global_settings() as settings:
+ settings["bail_cost_multiplier"] = multiplier
+
+ await ctx.send(_("Bail cost multiplier set to {multiplier}").format(
+ multiplier=multiplier
+ ))
+
+ @crime_set_global.command(name="togglebail")
+ async def toggle_bail(self, ctx: commands.Context, enabled: bool):
+ """Enable or disable the bail system"""
+ async with self.config.guild(ctx.guild).global_settings() as settings:
+ settings["allow_bail"] = enabled
+
+ if enabled:
+ await ctx.send(_("Bail system enabled!"))
+ else:
+ await ctx.send(_("Bail system disabled!"))
+
+ @crime_set_global.command(name="view")
+ async def view_settings(self, ctx: commands.Context):
+ """View all crime settings"""
+ # Get settings
+ crime_options = await self.config.guild(ctx.guild).crime_options()
+ global_settings = await self.config.guild(ctx.guild).global_settings()
+
+ # Build settings message
+ settings_lines = [
+ _("๐ **Global Settings**:"),
+ _(" โข Bail System: {enabled}").format(enabled=_("Enabled") if global_settings["allow_bail"] else _("Disabled")),
+ _(" โข Bail Cost Multiplier: {multiplier}").format(multiplier=global_settings["bail_cost_multiplier"]),
+ _(" โข Min Steal Balance: {amount}").format(amount=global_settings["min_steal_balance"]),
+ _(" โข Max Steal Amount: {amount}").format(amount=global_settings["max_steal_amount"]),
+ "",
+ _("๐ฏ **Crime Settings**:")
+ ]
+
+ for crime_type, data in crime_options.items():
+ if not data["enabled"]:
+ continue
+
+ settings_lines.extend([
+ f"\n**{crime_type.title()}**:",
+ _(" โข Success Rate: {rate}").format(rate=data["success_rate"]),
+ _(" โข Reward: {min_reward}-{max_reward}").format(
+ min_reward=data["min_reward"],
+ max_reward=data["max_reward"]
+ ),
+ _(" โข Cooldown: {time_remaining}").format(time_remaining=format_cooldown_time(data["cooldown"])),
+ _(" โข Jail Time: {time_remaining}").format(time_remaining=format_cooldown_time(data["jail_time"])),
+ _(" โข Fine Multiplier: {multiplier}").format(multiplier=data["fine_multiplier"])
+ ])
+
+ await ctx.send("\n".join(settings_lines))
+
+ async def send_to_jail(self, member: discord.Member, jail_time: int, channel: discord.TextChannel = None):
+ """Send a member to jail."""
+ async with self.config.member(member).all() as member_data:
+ # Check for reduced sentence perk
+ if "jail_reducer" in member_data.get("purchased_perks", []):
+ jail_time = int(jail_time * 0.8) # 20% shorter sentence
+
+ member_data["jail_until"] = int(time.time()) + jail_time
+ member_data["attempted_jailbreak"] = False # Reset jailbreak attempt when jailed
+
+ # Store the channel ID where they were jailed if provided
+ if channel:
+ member_data["jail_channel"] = channel.id
+
+ # If notifications are enabled, schedule a notification
+ if member_data.get("notify_on_release", False):
+ # Cancel any existing notification task
+ if member.id in self.notification_tasks:
+ self.notification_tasks[member.id].cancel()
+ # Schedule new notification
+ task = asyncio.create_task(self._schedule_release_notification(member, jail_time))
+ self.notification_tasks[member.id] = task
+
+ async def _cancel_notification(self, member: discord.Member):
+ """Cancel any pending release notification for a member."""
+ if member.id in self.notification_tasks:
+ self.notification_tasks[member.id].cancel()
+ del self.notification_tasks[member.id]
+
+ async def _schedule_release_notification(self, member: discord.Member, jail_time: int):
+ """Schedule a notification for when a member's jail sentence is over."""
+ await asyncio.sleep(jail_time)
+
+ # Double check they're actually out (in case sentence was extended)
+ remaining = await self.get_jail_time_remaining(member)
+ if remaining <= 0:
+ try:
+ # Only send if they still have notifications enabled
+ member_data = await self.config.member(member).all()
+ if member_data.get("notify_on_release", False):
+ # Try to send to the channel/thread they were jailed in
+ if "jail_channel" in member_data:
+ channel_id = member_data["jail_channel"]
+ # First try to get it as a thread
+ channel = member.guild.get_thread(channel_id)
+ # If not a thread, try as a regular channel
+ if channel is None:
+ channel = member.guild.get_channel(channel_id)
+
+ if channel:
+ await channel.send(f"๐ {member.mention} Your jail sentence is over! You're now free to commit crimes again.")
+ return
+
+ # Fallback to DM if channel not found or not stored
+ await member.send(f"๐ Your jail sentence is over! You're now free to commit crimes again.")
+ except (discord.Forbidden, discord.HTTPException):
+ pass # Ignore if we can't send the message
+
+ @crime.command(name="jail")
+ @commands.admin_or_permissions(administrator=True)
+ async def manual_jail(self, ctx: commands.Context, user: discord.Member, minutes: int):
+ """Manually put a user in jail.
+
+ Parameters
+ ----------
+ user : discord.Member
+ The user to jail
+ minutes : int
+ Number of minutes to jail them for
+ """
+ try:
+ if minutes <= 0:
+ await ctx.send("โ Jail time must be positive!")
+ return
+
+ # Convert minutes to seconds
+ jail_time = minutes * 60
+
+ # Check if user has reduced sentence perk
+ member_data = await self.config.member(user).all()
+ has_reducer = "jail_reducer" in member_data.get("purchased_perks", [])
+ if has_reducer:
+ jail_time = int(jail_time * 0.8) # 20% reduction
+
+ # Send user to jail with the current channel
+ await self.send_to_jail(user, jail_time, ctx.channel)
+
+ embed = discord.Embed(
+ title="โ๏ธ Manual Jail",
+ description=f"{user.mention} has been jailed by {ctx.author.mention}!",
+ color=discord.Color.red()
+ )
+
+ sentence_text = format_cooldown_time(jail_time)
+ if has_reducer:
+ sentence_text += " (Reduced by 20%)"
+
+ embed.add_field(
+ name="โฐ Sentence Duration",
+ value=sentence_text,
+ inline=True
+ )
+ embed.add_field(
+ name="๐
Release Time",
+ value=f"",
+ inline=True
+ )
+
+ await ctx.send(embed=embed)
+
+ except Exception as e:
+ await ctx.send(f"An error occurred while jailing the user: {str(e)}")
+
+ @crime.command(name="blackmarket")
+ async def crime_blackmarket(self, ctx: commands.Context):
+ """View the black market shop.
+
+ The black market offers special items and perks that can help with your criminal activities.
+ Items purchased here will appear in your inventory (!city inventory).
+ """
+ from .blackmarket import display_blackmarket
+ await display_blackmarket(self, ctx)
+
+ @crime_set.group(name="scenarios")
+ @commands.guild_only()
+ @commands.admin_or_permissions(administrator=True)
+ async def crime_set_scenarios(self, ctx: commands.Context):
+ """Manage custom random scenarios for this server.
+
+ Commands:
+ - add: Add a new custom scenario
+ - list: List all custom scenarios
+ - remove: Remove a custom scenario
+ """
+ pass
+
+ @crime_set_scenarios.command(name="add")
+ @commands.guild_only()
+ @commands.admin_or_permissions(administrator=True)
+ async def add_scenario(self, ctx: commands.Context):
+ """Add a custom random scenario to the crime pool.
+
+ This will guide you through creating a custom scenario by asking for:
+ - Scenario name
+ - Risk level (low, medium, high)
+ - Attempt text
+ - Success text (use {amount} and {currency} placeholders)
+ - Fail text
+
+ Custom scenarios are saved per server and persist through bot restarts.
+ """
+ # Start scenario creation process
+ await ctx.send("Let's create a new random scenario! I'll ask you for each piece of information.")
+
+ try:
+ # Get scenario name
+ await ctx.send("What would you like to name this scenario? (e.g. cookie_heist)")
+ msg = await self.bot.wait_for('message', check=lambda m: m.author == ctx.author and m.channel == ctx.channel, timeout=30)
+ name = msg.content.lower()
+
+ # Get risk level
+ await ctx.send("What risk level should this be? (low, medium, or high)")
+ while True:
+ msg = await self.bot.wait_for('message', check=lambda m: m.author == ctx.author and m.channel == ctx.channel, timeout=30)
+ risk = msg.content.lower()
+ if risk not in ["low", "medium", "high"]:
+ await ctx.send("Please enter either 'low', 'medium', or 'high'.")
+ else:
+ break
+
+ # Get attempt text
+ await ctx.send("Enter the attempt text (use {user} for the user's mention):")
+ msg = await self.bot.wait_for('message', check=lambda m: m.author == ctx.author and m.channel == ctx.channel, timeout=60)
+ attempt_text = msg.content
+
+ # Get success text
+ await ctx.send("Enter the success text (use {user} for the user's mention, {amount} for the reward amount, and {currency} for the currency name):")
+ msg = await self.bot.wait_for('message', check=lambda m: m.author == ctx.author and m.channel == ctx.channel, timeout=60)
+ success_text = msg.content
+
+ # Get fail text
+ await ctx.send("Enter the fail text (use {user} for the user's mention, {fine} for the fine amount, and {currency} for the currency name):")
+ msg = await self.bot.wait_for('message', check=lambda m: m.author == ctx.author and m.channel == ctx.channel, timeout=60)
+ fail_text = msg.content
+
+ # Set values based on risk level
+ if risk == "low":
+ success_rate = 0.7
+ min_reward = 100
+ max_reward = 300
+ jail_time = 180
+ fine_multiplier = 0.3
+ elif risk == "medium":
+ success_rate = 0.5
+ min_reward = 300
+ max_reward = 800
+ jail_time = 300
+ fine_multiplier = 0.4
+ else: # high
+ success_rate = 0.3
+ min_reward = 800
+ max_reward = 2000
+ jail_time = 600
+ fine_multiplier = 0.5
+
+ # Create new scenario
+ new_scenario = {
+ "name": name,
+ "risk": risk,
+ "min_reward": min_reward,
+ "max_reward": max_reward,
+ "success_rate": success_rate,
+ "jail_time": jail_time,
+ "fine_multiplier": fine_multiplier,
+ "attempt_text": attempt_text,
+ "success_text": success_text,
+ "fail_text": fail_text
+ }
+
+ # Add to guild's custom scenarios
+ async with self.config.guild(ctx.guild).custom_scenarios() as scenarios:
+ scenarios.append(new_scenario)
+
+ # Send confirmation
+ embed = discord.Embed(
+ title="โ
Custom Scenario Added!",
+ description=f"Your scenario '{name}' has been added to this server's random crime pool.",
+ color=discord.Color.green()
+ )
+ embed.add_field(name="Risk Level", value=risk.title(), inline=True)
+ embed.add_field(name="Success Rate", value=f"{int(success_rate * 100)}%", inline=True)
+ embed.add_field(name="Reward Range", value=f"{min_reward:,} - {max_reward:,}", inline=True)
+
+ await ctx.send(embed=embed)
+
+ except asyncio.TimeoutError:
+ await ctx.send("โ Scenario creation timed out. Please try again.")
+
+ @crime_set_scenarios.command(name="list")
+ @commands.guild_only()
+ @commands.admin_or_permissions(administrator=True)
+ async def list_scenarios(self, ctx: commands.Context):
+ """List all custom scenarios in this server."""
+ custom_scenarios = await self.config.guild(ctx.guild).custom_scenarios()
+
+ if not custom_scenarios:
+ await ctx.send("This server has no custom scenarios.")
+ return
+
+ # Create embed to display scenarios
+ embed = discord.Embed(
+ title="๐ Custom Random Scenarios",
+ description=f"This server has {len(custom_scenarios)} custom scenarios:",
+ color=await ctx.embed_color()
+ )
+
+ for scenario in custom_scenarios:
+ # Format success rate as percentage
+ success_rate = int(scenario["success_rate"] * 100)
+
+ # Create field content
+ details = [
+ f"**Risk Level:** {scenario['risk'].title()}",
+ f"**Success Rate:** {success_rate}%",
+ f"**Reward:** {scenario['min_reward']:,} - {scenario['max_reward']:,}",
+ f"**Jail Time:** {scenario['jail_time']} seconds",
+ f"**Fine Multiplier:** {scenario['fine_multiplier']}"
+ ]
+
+ embed.add_field(
+ name=f"๐ฒ {scenario['name']}",
+ value="\n".join(details),
+ inline=False
+ )
+
+ await ctx.send(embed=embed)
+
+ @crime_set_scenarios.command(name="remove")
+ @commands.guild_only()
+ @commands.admin_or_permissions(administrator=True)
+ async def remove_scenario(self, ctx: commands.Context, scenario_name: str):
+ """Remove a custom scenario by name.
+
+ Example:
+ - [p]crimeset scenarios remove cookie_heist
+ """
+ async with self.config.guild(ctx.guild).custom_scenarios() as scenarios:
+ # Find scenario with matching name
+ for i, scenario in enumerate(scenarios):
+ if scenario["name"].lower() == scenario_name.lower():
+ removed = scenarios.pop(i)
+ await ctx.send(f"โ
Removed custom scenario: {removed['name']}")
+ return
+
+ await ctx.send("โ No custom scenario found with that name.")
diff --git a/city/crime/data.py b/city/crime/data.py
new file mode 100644
index 0000000..89115db
--- /dev/null
+++ b/city/crime/data.py
@@ -0,0 +1,105 @@
+"""Data and settings for the crime system."""
+
+# Define available crimes and their properties
+CRIME_TYPES = {
+ "pickpocket": {
+ "requires_target": True,
+ "min_reward": 150,
+ "max_reward": 500,
+ "success_rate": 0.6,
+ "cooldown": 600, # 10 minutes
+ "jail_time": 3600, # 1 hour if caught
+ "risk": "low",
+ "enabled": True,
+ "fine_multiplier": 0.35, # 35% of max reward as fine
+ "min_steal_percentage": 0.01, # Steal 1-10% of target's credits
+ "max_steal_percentage": 0.10
+ },
+ "mugging": {
+ "requires_target": True,
+ "min_reward": 400,
+ "max_reward": 1500,
+ "success_rate": 0.6,
+ "cooldown": 1800, # 30 minutes
+ "jail_time": 5400, # 1 hour 30 minutes if caught
+ "risk": "medium",
+ "enabled": True,
+ "fine_multiplier": 0.4, # 40% of max reward as fine
+ "min_steal_percentage": 0.15, # Steal 15-25% of target's credits
+ "max_steal_percentage": 0.25
+ },
+ "rob_store": {
+ "requires_target": False,
+ "min_reward": 500,
+ "max_reward": 2000,
+ "success_rate": 0.5,
+ "cooldown": 21600, # 6 hours
+ "jail_time": 10800, # 3 hours if caught
+ "risk": "medium",
+ "enabled": True,
+ "fine_multiplier": 0.4, # 45% of max reward as fine
+ "steal_percentage": 0
+ },
+ "bank_heist": {
+ "requires_target": False,
+ "min_reward": 1500,
+ "max_reward": 5000,
+ "success_rate": 0.4,
+ "cooldown": 86400, # 1 day
+ "jail_time": 14400, # 4 hours if caught
+ "risk": "high",
+ "enabled": True,
+ "fine_multiplier": 0.4, # 40% of max reward as fine
+ "steal_percentage": 0
+ },
+ "random": {
+ "requires_target": False,
+ "min_reward": 100, # Will be overridden by scenario
+ "max_reward": 3000, # Will be overridden by scenario
+ "success_rate": 0.5, # Will be overridden by scenario
+ "cooldown": 3600, # 1 hour
+ "jail_time": 600, # Will be overridden by scenario
+ "risk": "random", # Will be determined by scenario
+ "enabled": True,
+ "fine_multiplier": 0.5, # Will be overridden by scenario
+ "steal_percentage": 0
+ }
+}
+
+# Default guild settings
+DEFAULT_GUILD = {
+ "crime_options": CRIME_TYPES,
+ "global_settings": {
+ "allow_bail": True,
+ "bail_cost_multiplier": 1.6,
+ "min_steal_balance": 100,
+ "max_steal_amount": 1000,
+ "default_jail_time": 1800, # 30 minutes
+ "default_fine_multiplier": 0.5,
+ "protect_low_balance": True, # Prevent stealing from users with very low balance
+ "show_success_rate": True, # Show success rate in crime messages
+ "show_fine_amount": True, # Show potential fine amounts
+ "enable_random_events": True # Enable random events during crimes
+ },
+ "custom_scenarios": [] # List to store custom scenarios for this guild
+}
+
+# Default member settings specific to crime
+DEFAULT_MEMBER = {
+ "jail_time": 0, # Total time to serve
+ "jail_started": 0, # When jail time started
+ "attempted_jailbreak": False, # Whether attempted jailbreak this sentence
+ "cooldowns": {}, # Crime cooldowns
+ "total_successful_crimes": 0,
+ "total_failed_crimes": 0,
+ "total_fines_paid": 0,
+ "total_credits_earned": 0,
+ "largest_heist": 0, # Track largest successful heist
+ "total_stolen_from": 0, # Amount stolen from others
+ "total_stolen_by": 0, # Amount stolen by others
+ "total_bail_paid": 0, # Amount spent on bail
+ "notify_on_release": False, # Whether to ping user when jail sentence is over
+ "current_streak": 0, # Current successful crime streak
+ "highest_streak": 0, # Highest streak achieved
+ "streak_multiplier": 1.0, # Current reward multiplier from streak
+}
diff --git a/city/crime/jail.py b/city/crime/jail.py
new file mode 100644
index 0000000..2aff052
--- /dev/null
+++ b/city/crime/jail.py
@@ -0,0 +1,547 @@
+"""Jail management system for the City cog.
+
+
+CURRENTLY NOT IN USE AND WIP
+
+"""
+from typing import Optional, Dict, Any, Tuple, List
+import time
+import asyncio
+import random
+import discord
+from redbot.core import Config, bank
+from redbot.core.bot import Red
+from redbot.core.utils.chat_formatting import humanize_number
+from redbot.core.utils.predicates import MessagePredicate
+from redbot.core.i18n import Translator
+from .scenarios import get_random_jailbreak_scenario
+
+_ = Translator("City", __file__)
+
+class JailException(Exception):
+ """Base exception for jail-related errors."""
+ pass
+
+class JailStateError(JailException):
+ """Raised when there's an error with jail state management."""
+ pass
+
+class JailNotificationError(JailException):
+ """Raised when there's an error with jail notifications."""
+ pass
+
+class BailError(JailException):
+ """Raised when there's an error with bail operations."""
+ pass
+
+class JailbreakError(JailException):
+ """Raised when there's an error with jailbreak operations."""
+ pass
+
+class PerkError(JailException):
+ """Raised when there's an error with perk operations."""
+ pass
+
+class PerkManager:
+ """Manages jail-related perks."""
+
+ def __init__(self, config: Config):
+ self.config = config
+
+ async def has_perk(self, member: discord.Member, perk_name: str) -> bool:
+ """Check if a member has a specific perk."""
+ try:
+ member_data = await self.config.member(member).all()
+ return perk_name in member_data.get("purchased_perks", [])
+ except Exception as e:
+ raise PerkError(f"Failed to check perk status: {str(e)}")
+
+ async def apply_sentence_reduction(self, member: discord.Member, jail_time: int) -> int:
+ """Apply sentence reduction if member has the jail_reducer perk."""
+ try:
+ if await self.has_perk(member, "jail_reducer"):
+ return int(jail_time * 0.8) # 20% reduction
+ return jail_time
+ except Exception as e:
+ raise PerkError(f"Failed to apply sentence reduction: {str(e)}")
+
+ async def should_notify(self, member: discord.Member) -> bool:
+ """Check if member should receive jail notifications."""
+ try:
+ member_data = await self.config.member(member).all()
+ has_notifier = await self.has_perk(member, "jail_notifier")
+ notify_enabled = member_data.get("notify_on_release", False)
+ return has_notifier and notify_enabled
+ except Exception as e:
+ raise PerkError(f"Failed to check notification status: {str(e)}")
+
+ async def toggle_notifications(self, member: discord.Member) -> bool:
+ """Toggle jail release notifications for a member."""
+ try:
+ if not await self.has_perk(member, "jail_notifier"):
+ raise PerkError("You don't have the jail notifier perk!")
+
+ async with self.config.member(member).all() as member_data:
+ current = member_data.get("notify_on_release", False)
+ member_data["notify_on_release"] = not current
+ return not current
+ except Exception as e:
+ raise PerkError(f"Failed to toggle notifications: {str(e)}")
+
+class JailbreakScenario:
+ """Represents a jailbreak attempt scenario."""
+
+ def __init__(self, data: Dict[str, Any]):
+ self.name = data["name"]
+ self.attempt_text = data["attempt_text"]
+ self.success_text = data["success_text"]
+ self.fail_text = data["fail_text"]
+ self.base_chance = data["base_chance"]
+ self.events = data["events"]
+
+ @property
+ def random_events(self) -> List[Dict[str, Any]]:
+ """Get 1-3 random events for this scenario."""
+ num_events = random.randint(1, 3)
+ return random.sample(self.events, num_events)
+
+ def format_text(self, text: str, **kwargs) -> str:
+ """Format scenario text with given parameters."""
+ return _(text).format(**kwargs)
+
+class JailManager:
+ """Manages all jail-related functionality."""
+
+ def __init__(self, bot: Red, config: Config):
+ self.bot = bot
+ self.config = config
+ self.notification_tasks: Dict[int, asyncio.Task] = {} # member_id: task
+ self.perk_manager = PerkManager(config)
+
+ async def get_jail_state(self, member: discord.Member) -> Dict[str, Any]:
+ """Get the complete jail state for a member."""
+ try:
+ member_data = await self.config.member(member).all()
+ return {
+ "jail_until": member_data.get("jail_until", 0),
+ "attempted_jailbreak": member_data.get("attempted_jailbreak", False),
+ "jail_channel": member_data.get("jail_channel", None),
+ "notify_on_release": member_data.get("notify_on_release", False),
+ "reduced_sentence": "jail_reducer" in member_data.get("purchased_perks", []),
+ "original_sentence": member_data.get("original_sentence", 0)
+ }
+ except Exception as e:
+ raise JailStateError(f"Failed to get jail state: {str(e)}")
+
+ async def update_jail_state(self, member: discord.Member, state: Dict[str, Any]) -> None:
+ """Update the jail state for a member."""
+ try:
+ async with self.config.member(member).all() as member_data:
+ member_data["jail_until"] = state.get("jail_until", member_data.get("jail_until", 0))
+ member_data["attempted_jailbreak"] = state.get("attempted_jailbreak", member_data.get("attempted_jailbreak", False))
+ member_data["jail_channel"] = state.get("jail_channel", member_data.get("jail_channel", None))
+ member_data["notify_on_release"] = state.get("notify_on_release", member_data.get("notify_on_release", False))
+ member_data["original_sentence"] = state.get("original_sentence", member_data.get("original_sentence", 0))
+ except Exception as e:
+ raise JailStateError(f"Failed to update jail state: {str(e)}")
+
+ async def get_jail_time_remaining(self, member: discord.Member) -> int:
+ """Get remaining jail time in seconds."""
+ try:
+ state = await self.get_jail_state(member)
+ jail_until = state["jail_until"]
+
+ if not jail_until:
+ return 0
+
+ current_time = int(time.time())
+ remaining = max(0, jail_until - current_time)
+
+ # Clear jail if time is up
+ if remaining == 0 and jail_until != 0:
+ await self.clear_jail_state(member)
+
+ return remaining
+ except Exception as e:
+ raise JailStateError(f"Failed to get remaining jail time: {str(e)}")
+
+ async def send_to_jail(self, member: discord.Member, jail_time: int, channel: Optional[discord.TextChannel] = None) -> None:
+ """Send a member to jail."""
+ try:
+ # Get current state
+ state = await self.get_jail_state(member)
+ original_time = jail_time
+
+ # Apply sentence reduction if they have the perk
+ jail_time = await self.perk_manager.apply_sentence_reduction(member, jail_time)
+
+ # Update state
+ new_state = {
+ "jail_until": int(time.time()) + jail_time,
+ "attempted_jailbreak": False, # Reset on new sentence
+ "jail_channel": channel.id if channel else None,
+ "original_sentence": original_time
+ }
+ await self.update_jail_state(member, new_state)
+
+ # Handle notification scheduling
+ if await self.perk_manager.should_notify(member):
+ await self._schedule_release_notification(member, jail_time, channel)
+
+ except Exception as e:
+ raise JailStateError(f"Failed to send member to jail: {str(e)}")
+
+ async def clear_jail_state(self, member: discord.Member) -> None:
+ """Clear all jail-related state for a member."""
+ try:
+ # Cancel any pending notifications
+ await self._cancel_notification(member)
+
+ # Clear jail state
+ await self.update_jail_state(member, {
+ "jail_until": 0,
+ "attempted_jailbreak": False,
+ "jail_channel": None,
+ "original_sentence": 0
+ })
+ except Exception as e:
+ raise JailStateError(f"Failed to clear jail state: {str(e)}")
+
+ async def _schedule_release_notification(self, member: discord.Member, jail_time: int, channel: Optional[discord.TextChannel] = None) -> None:
+ """Schedule a notification for when a member's jail sentence is over."""
+ try:
+ # Cancel any existing notification
+ await self._cancel_notification(member)
+
+ # Create new notification task
+ task = asyncio.create_task(self._notification_handler(member, jail_time, channel))
+ self.notification_tasks[member.id] = task
+
+ except Exception as e:
+ raise JailNotificationError(f"Failed to schedule release notification: {str(e)}")
+
+ async def _cancel_notification(self, member: discord.Member) -> None:
+ """Cancel a pending release notification."""
+ try:
+ if member.id in self.notification_tasks:
+ self.notification_tasks[member.id].cancel()
+ del self.notification_tasks[member.id]
+ except Exception as e:
+ raise JailNotificationError(f"Failed to cancel notification: {str(e)}")
+
+ async def _notification_handler(self, member: discord.Member, jail_time: int, channel: Optional[discord.TextChannel] = None) -> None:
+ """Handle the notification process for jail release."""
+ try:
+ await asyncio.sleep(jail_time)
+
+ # Verify they're actually out (in case sentence was extended)
+ remaining = await self.get_jail_time_remaining(member)
+ if remaining <= 0:
+ state = await self.get_jail_state(member)
+ if state["notify_on_release"]:
+ await self._send_notification(member, channel)
+ except asyncio.CancelledError:
+ pass # Task was cancelled, ignore
+ except Exception as e:
+ raise JailNotificationError(f"Notification handler failed: {str(e)}")
+
+ async def _send_notification(self, member: discord.Member, channel: Optional[discord.TextChannel] = None) -> None:
+ """Send the actual notification message."""
+ try:
+ message = f"๐ {member.mention} Your jail sentence is over! You're now free to commit crimes again."
+
+ if channel:
+ await channel.send(message)
+ else:
+ try:
+ await member.send(message)
+ except (discord.Forbidden, discord.HTTPException):
+ pass # Can't DM the user, silently fail
+ except Exception as e:
+ raise JailNotificationError(f"Failed to send notification: {str(e)}")
+
+ def is_in_jail(self, member: discord.Member) -> bool:
+ """Quick check if a member is currently in jail."""
+ return self.get_jail_time_remaining(member) > 0
+
+ async def calculate_bail_cost(self, member: discord.Member) -> Tuple[int, float]:
+ """Calculate bail cost based on remaining jail time.
+
+ Returns:
+ Tuple[int, float]: (bail_cost, multiplier)
+ """
+ try:
+ # Get remaining time and settings
+ remaining_time = await self.get_jail_time_remaining(member)
+ if remaining_time <= 0:
+ raise BailError("Member is not in jail")
+
+ settings = await self.config.guild(member.guild).global_settings()
+ multiplier = float(settings.get("bail_cost_multiplier", 1.5))
+
+ # Calculate cost (remaining minutes * multiplier)
+ bail_cost = int(multiplier * (remaining_time / 60)) # Convert seconds to minutes
+
+ return bail_cost, multiplier
+
+ except Exception as e:
+ raise BailError(f"Failed to calculate bail cost: {str(e)}")
+
+ async def can_pay_bail(self, member: discord.Member) -> Tuple[bool, int, str]:
+ """Check if a member can pay bail.
+
+ Returns:
+ Tuple[bool, int, str]: (can_pay, cost, currency_name)
+ """
+ try:
+ # Check if bail is allowed
+ settings = await self.config.guild(member.guild).global_settings()
+ if not settings.get("allow_bail", True):
+ raise BailError("Bail is not allowed in this server")
+
+ # Calculate bail cost
+ bail_cost, _ = await self.calculate_bail_cost(member)
+
+ # Get currency info
+ currency_name = await bank.get_currency_name(member.guild)
+ can_pay = await bank.can_spend(member, bail_cost)
+
+ return can_pay, bail_cost, currency_name
+
+ except Exception as e:
+ raise BailError(f"Failed to check bail payment: {str(e)}")
+
+ async def process_bail_payment(self, member: discord.Member) -> Tuple[int, int, str]:
+ """Process bail payment and release from jail.
+
+ Returns:
+ Tuple[int, int, str]: (bail_cost, new_balance, currency_name)
+ """
+ try:
+ # Verify they can pay
+ can_pay, bail_cost, currency_name = await self.can_pay_bail(member)
+ if not can_pay:
+ raise BailError(f"Insufficient funds to pay {bail_cost} {currency_name}")
+
+ # Process payment
+ await bank.withdraw_credits(member, bail_cost)
+ new_balance = await bank.get_balance(member)
+
+ # Update stats
+ async with self.config.member(member).all() as member_data:
+ member_data["total_bail_paid"] = member_data.get("total_bail_paid", 0) + bail_cost
+
+ # Release from jail
+ await self.clear_jail_state(member)
+
+ return bail_cost, new_balance, currency_name
+
+ except Exception as e:
+ raise BailError(f"Failed to process bail payment: {str(e)}")
+
+ async def format_bail_embed(self, member: discord.Member) -> discord.Embed:
+ """Format bail information into an embed."""
+ try:
+ # Get bail information
+ remaining_time = await self.get_jail_time_remaining(member)
+ bail_cost, multiplier = await self.calculate_bail_cost(member)
+ current_balance = await bank.get_balance(member)
+ currency_name = await bank.get_currency_name(member.guild)
+
+ # Get member data for perk check
+ state = await self.get_jail_state(member)
+
+ # Create embed
+ embed = discord.Embed(
+ title="๐ฐ Bail Payment Available",
+ description=(
+ "You can pay bail to get out of jail immediately, or wait out your sentence.\n\n"
+ f"**Time Remaining:** {self._format_time(remaining_time)}"
+ + (" (Reduced by 20%)" if state["reduced_sentence"] else "") + "\n"
+ f"**Bail Cost:** {bail_cost:,} {currency_name}\n"
+ f"**Current Balance:** {current_balance:,} {currency_name}\n\n"
+ f"*Bail cost is calculated as: remaining minutes ร {multiplier}*"
+ ),
+ color=discord.Color.gold(),
+ timestamp=discord.utils.utcnow()
+ )
+ embed.set_footer(text=f"Requested by {member.display_name}", icon_url=member.display_avatar.url)
+
+ return embed
+
+ except Exception as e:
+ raise BailError(f"Failed to format bail embed: {str(e)}")
+
+ def _format_time(self, seconds: int) -> str:
+ """Format seconds into a human-readable string."""
+ minutes = seconds // 60
+ remaining_seconds = seconds % 60
+ return f"{minutes}m {remaining_seconds}s"
+
+ async def get_jailbreak_scenario(self) -> JailbreakScenario:
+ """Get a random jailbreak scenario."""
+ try:
+ # Get scenario from scenarios.py
+ scenario_data = get_random_jailbreak_scenario()
+ return JailbreakScenario(scenario_data)
+ except Exception as e:
+ raise JailbreakError(f"Failed to get jailbreak scenario: {str(e)}")
+
+ async def process_jailbreak_attempt(self, member: discord.Member, channel: discord.TextChannel) -> Tuple[bool, discord.Embed, List[str]]:
+ """Process a jailbreak attempt.
+
+ Returns:
+ Tuple[bool, discord.Embed, List[str]]: (success, result_embed, event_messages)
+ """
+ try:
+ # Verify member is in jail
+ if not await self.is_in_jail(member):
+ raise JailbreakError("Member is not in jail")
+
+ # Check if already attempted
+ state = await self.get_jail_state(member)
+ if state["attempted_jailbreak"]:
+ raise JailbreakError("Already attempted jailbreak this sentence")
+
+ # Mark attempt
+ await self.update_jail_state(member, {"attempted_jailbreak": True})
+
+ # Get scenario and process events
+ scenario = await self.get_jailbreak_scenario()
+ success_chance = scenario.base_chance
+ event_messages = []
+
+ # Process random events
+ for event in scenario.random_events:
+ event_text = event["text"]
+ currency_name = await bank.get_currency_name(member.guild)
+ event_text = event_text.format(currency=currency_name)
+
+ # Apply chance modifiers
+ if "chance_bonus" in event:
+ success_chance = min(1.0, success_chance + event["chance_bonus"])
+ elif "chance_penalty" in event:
+ success_chance = max(0.05, success_chance - event["chance_penalty"])
+
+ # Apply currency effects
+ if "currency_bonus" in event:
+ await bank.deposit_credits(member, event["currency_bonus"])
+ event_text += f" (+{event['currency_bonus']} {currency_name})"
+ elif "currency_penalty" in event:
+ await bank.withdraw_credits(member, event["currency_penalty"])
+ event_text += f" (-{event['currency_penalty']} {currency_name})"
+
+ event_messages.append(event_text)
+
+ # Roll for success
+ success = random.random() < success_chance
+
+ if success:
+ # Clear jail time
+ await self.clear_jail_state(member)
+ embed = discord.Embed(
+ title="๐ Successful Jailbreak!",
+ description=scenario.format_text(scenario.success_text, user=member.mention),
+ color=discord.Color.green()
+ )
+ else:
+ # Add 30% more time
+ remaining_time = await self.get_jail_time_remaining(member)
+ added_time = int(remaining_time * 0.3)
+ new_until = int(time.time()) + remaining_time + added_time
+ await self.update_jail_state(member, {"jail_until": new_until})
+
+ # Create fail embed
+ embed = discord.Embed(
+ title="โ๏ธ Failed Jailbreak!",
+ description=scenario.format_text(scenario.fail_text, user=member.mention),
+ color=discord.Color.red()
+ )
+
+ # Add penalty info
+ minutes = remaining_time // 60
+ seconds = remaining_time % 60
+ new_minutes = int((remaining_time * 1.3) // 60)
+ new_seconds = int((remaining_time * 1.3) % 60)
+ embed.add_field(
+ name="โ๏ธ Penalty",
+ value=f"Your sentence has been increased by 30%!\n({minutes}m {seconds}s + 30% = โฐ {new_minutes}m {new_seconds}s)",
+ inline=True
+ )
+
+ # Add chance field
+ embed.add_field(
+ name="๐ฒ Final Escape Chance",
+ value=f"{success_chance:.1%}",
+ inline=True
+ )
+
+ return success, embed, event_messages
+
+ except Exception as e:
+ raise JailbreakError(f"Failed to process jailbreak attempt: {str(e)}")
+
+ async def format_jailbreak_embed(self, member: discord.Member, scenario: JailbreakScenario) -> discord.Embed:
+ """Format the initial jailbreak attempt embed."""
+ try:
+ embed = discord.Embed(
+ title="๐ Jailbreak Attempt",
+ description=scenario.format_text(scenario.attempt_text, user=member.mention),
+ color=discord.Color.gold()
+ )
+ embed.add_field(
+ name="๐ Base Success Chance",
+ value=f"{scenario.base_chance:.1%}",
+ inline=True
+ )
+ return embed
+ except Exception as e:
+ raise JailbreakError(f"Failed to format jailbreak embed: {str(e)}")
+
+ async def format_jail_status(self, member: discord.Member) -> discord.Embed:
+ """Format jail status into an embed."""
+ try:
+ state = await self.get_jail_state(member)
+ remaining_time = await self.get_jail_time_remaining(member)
+
+ if remaining_time <= 0:
+ return discord.Embed(
+ title="๐ Not in Jail",
+ description=f"{member.mention} is not currently in jail.",
+ color=discord.Color.green()
+ )
+
+ embed = discord.Embed(
+ title="โ๏ธ Jail Status",
+ description=f"{member.mention} is currently in jail!",
+ color=discord.Color.red()
+ )
+
+ # Add time info
+ minutes = remaining_time // 60
+ seconds = remaining_time % 60
+ embed.add_field(
+ name="โฐ Time Remaining",
+ value=f"{minutes}m {seconds}s" + (" (Reduced by 20%)" if state["reduced_sentence"] else ""),
+ inline=True
+ )
+
+ # Add notification status if they have the perk
+ if await self.perk_manager.has_perk(member, "jail_notifier"):
+ embed.add_field(
+ name="๐ Notifications",
+ value="Enabled" if state["notify_on_release"] else "Disabled",
+ inline=True
+ )
+
+ # Add jailbreak attempt status
+ embed.add_field(
+ name="๐ Jailbreak Attempt",
+ value="Already Attempted" if state["attempted_jailbreak"] else "Not Attempted",
+ inline=True
+ )
+
+ return embed
+
+ except Exception as e:
+ raise JailStateError(f"Failed to format jail status: {str(e)}")
diff --git a/city/crime/scenarios.py b/city/crime/scenarios.py
new file mode 100644
index 0000000..b24e44f
--- /dev/null
+++ b/city/crime/scenarios.py
@@ -0,0 +1,1350 @@
+"""Random crime scenarios for the crime system."""
+
+import random
+import discord
+from redbot.core import bank, commands, Config
+from typing import Union, List, Dict, Optional
+
+# Constants for risk levels and success rates
+RISK_LOW = "low"
+RISK_MEDIUM = "medium"
+RISK_HIGH = "high"
+
+SUCCESS_RATE_HIGH = 0.75
+SUCCESS_RATE_MEDIUM = 0.50
+SUCCESS_RATE_LOW = 0.30
+
+async def format_text(text: str, ctx: Union[commands.Context, discord.Interaction], **kwargs) -> str:
+ """Format text by replacing placeholders with actual values.
+
+ Args:
+ text: Text containing placeholders
+ ctx: Either a Context or Interaction object
+ **kwargs: Additional format arguments (credits_bonus, credits_penalty)
+ """
+ if hasattr(ctx, 'guild'):
+ # Context object
+ guild = ctx.guild
+ user = ctx.user if hasattr(ctx, 'user') else ctx.author
+ else:
+ # Interaction object
+ guild = ctx.guild
+ user = ctx.user
+
+ currency_name = await bank.get_currency_name(guild)
+ format_args = {
+ 'currency': currency_name,
+ 'user': user.mention if "{user}" in text else user.display_name
+ }
+
+ # Add any additional format arguments
+ format_args.update(kwargs)
+
+ return text.format(**format_args)
+
+def get_crime_event(crime_type: str) -> list:
+ """Get a list of random events for a specific crime type.
+ Returns a list containing 1-3 events:
+ - First event is guaranteed
+ - Second event has 75% chance
+ - Third event has 50% chance
+ - Fourth event has 10% chance
+ """
+ if crime_type not in CRIME_EVENTS:
+ return []
+
+ events = []
+ available_events = CRIME_EVENTS[crime_type].copy()
+
+ # First event is guaranteed
+ if available_events:
+ event = random.choice(available_events)
+ events.append(event)
+ available_events.remove(event)
+
+ # Second event has 75% chance
+ if available_events and random.random() < 0.75:
+ event = random.choice(available_events)
+ events.append(event)
+ available_events.remove(event)
+
+ # Third event has 50% chance
+ if available_events and random.random() < 0.50:
+ event = random.choice(available_events)
+ events.append(event)
+ available_events.remove(event)
+
+ # Fourth event has 10% chance
+ if available_events and random.random() < 0.10:
+ event = random.choice(available_events)
+ events.append(event)
+
+ return events
+
+async def get_all_scenarios(config: Config, guild: discord.Guild) -> List[Dict]:
+ """Get all available random scenarios.
+
+ This includes both default scenarios and any custom scenarios added by the guild.
+ If custom_scenarios_only is enabled, only returns custom scenarios.
+ """
+ # Get default scenarios
+ scenarios = RANDOM_SCENARIOS.copy()
+
+ # Get custom scenarios for this guild
+ custom_scenarios = await config.guild(guild).custom_scenarios()
+
+ # Add custom scenarios
+ scenarios.extend(custom_scenarios)
+
+ return scenarios
+
+async def add_custom_scenario(config: Config, guild: discord.Guild, scenario: Dict) -> None:
+ """Add a custom scenario to the guild's config."""
+ async with config.guild(guild).custom_scenarios() as scenarios:
+ scenarios.append(scenario)
+
+def get_random_scenario(scenarios: List[Dict]) -> Dict:
+ """Get a random scenario from the list."""
+ return random.choice(scenarios)
+
+def get_random_jailbreak_scenario() -> Dict:
+ """Get a random prison break scenario.
+
+ Returns:
+ Dict: A dictionary containing the scenario data with keys:
+ - name: Scenario identifier
+ - attempt_text: Text shown when attempting
+ - success_text: Text shown on success
+ - fail_text: Text shown on failure
+ - base_chance: Base success chance (0.0 to 1.0)
+ - events: List of possible random events that can affect success chance or rewards
+ """
+ return random.choice(PRISON_BREAK_SCENARIOS)
+
+
+# Each scenario has:
+# - name: Unique identifier for the scenario
+# - risk: Risk level (low, medium, high)
+# - min_reward: Minimum reward amount
+# - max_reward: Maximum reward amount
+# - success_rate: Chance of success (0.0 to 1.0)
+# - jail_time: Time in jail if caught (in seconds)
+# - fine_multiplier: Multiplier for fine calculation
+# - attempt_text: Message shown when attempting the crime
+# - success_text: Message shown on success
+# - fail_text: Message shown on failure
+
+RANDOM_SCENARIOS = [
+ {
+ "name": "ice_cream_heist",
+ "risk": RISK_LOW,
+ "min_reward": 100,
+ "max_reward": 300,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.3,
+ "attempt_text": "๐ฆ {user} sneaks into the ice cream shop after hours...",
+ "success_text": "๐ฆ {user} successfully raided the ice cream vault and made {amount} {currency}! Free ice cream for everyone!",
+ "fail_text": "๐ฆ {user} slipped on a banana split and got caught by the night guard!"
+ },
+ {
+ "name": "cat_burglar",
+ "risk": RISK_MEDIUM,
+ "min_reward": 400,
+ "max_reward": 800,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 3600, # 60 minutes
+ "fine_multiplier": 0.4,
+ "attempt_text": "๐ฑ {user} scales the mansion wall to steal the prized cat statue...",
+ "success_text": "๐ฑ {user} purrfectly executed the heist and stole the golden cat statue, earning {amount} {currency}!",
+ "fail_text": "๐ฑ {user} was caught when the real cats triggered the alarm system!"
+ },
+ {
+ "name": "train_robbery",
+ "risk": RISK_HIGH,
+ "min_reward": 500,
+ "max_reward": 2500,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 7200, # 120 minutes
+ "fine_multiplier": 0.5,
+ "attempt_text": "๐ {user} jumps onto the moving train carrying valuable cargo...",
+ "success_text": "๐ {user} pulled off a classic train robbery and escaped with {amount} {currency}!",
+ "fail_text": "๐ {user} got caught between train cars and was arrested at the next station!"
+ },
+ {
+ "name": "casino_con",
+ "risk": RISK_HIGH,
+ "min_reward": 800,
+ "max_reward": 2500,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 5400, # 90 minutes
+ "fine_multiplier": 0.45,
+ "attempt_text": "๐ฐ {user} approaches the casino with their master plan...",
+ "success_text": "๐ฐ {user} conned the casino and walked away with {amount} {currency}!",
+ "fail_text": "๐ฐ {user} was caught counting cards and was thrown out by security!"
+ },
+ {
+ "name": "food_truck_heist",
+ "risk": RISK_LOW,
+ "min_reward": 200,
+ "max_reward": 500,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.35,
+ "attempt_text": "๐ {user} sneaks up to the famous food truck at midnight...",
+ "success_text": "๐ {user} stole the secret recipe and a truck full of tacos, making {amount} {currency}!",
+ "fail_text": "๐ {user} was caught with their hands in the salsa jar!"
+ },
+ {
+ "name": "art_gallery_heist",
+ "risk": RISK_HIGH,
+ "min_reward": 900,
+ "max_reward": 2800,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 9000, # 150 minutes
+ "fine_multiplier": 0.48,
+ "attempt_text": "๐จ {user} infiltrates the art gallery during a fancy exhibition...",
+ "success_text": "๐จ {user} swapped the real painting with a forgery and sold it for {amount} {currency}!",
+ "fail_text": "๐จ {user} tripped the laser security system and got caught red-handed!"
+ },
+ {
+ "name": "candy_store_raid",
+ "risk": RISK_LOW,
+ "min_reward": 150,
+ "max_reward": 400,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.32,
+ "attempt_text": "๐ฌ {user} sneaks into the candy store with an empty backpack...",
+ "success_text": "๐ฌ {user} filled their bag with premium chocolates and rare candies, worth {amount} {currency}!",
+ "fail_text": "๐ฌ {user} got stuck in the gummy bear display and was caught by the owner!"
+ },
+ {
+ "name": "game_store_heist",
+ "risk": RISK_MEDIUM,
+ "min_reward": 500,
+ "max_reward": 1200,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 4320, # 72 minutes
+ "fine_multiplier": 0.42,
+ "attempt_text": "๐ฎ {user} attempts to break into the game store's storage room...",
+ "success_text": "๐ฎ {user} made off with a box of unreleased games and rare collectibles worth {amount} {currency}!",
+ "fail_text": "๐ฎ {user} got distracted playing the demo console and was caught by security!"
+ },
+ {
+ "name": "pet_shop_caper",
+ "risk": RISK_LOW,
+ "min_reward": 180,
+ "max_reward": 450,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.33,
+ "attempt_text": "๐น {user} sneaks into the pet shop with a suspicious large coat...",
+ "success_text": "๐น {user} smuggled out the rare exotic pets and sold them to collectors for {amount} {currency}!",
+ "fail_text": "๐น {user} got caught when all the puppies started barking at once!"
+ },
+ {
+ "name": "music_store_theft",
+ "risk": RISK_MEDIUM,
+ "min_reward": 600,
+ "max_reward": 1500,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 3240, # 54 minutes
+ "fine_multiplier": 0.43,
+ "attempt_text": "๐ธ {user} picks the lock of the vintage music store...",
+ "success_text": "๐ธ {user} stole a legendary signed guitar and some rare vinyl records worth {amount} {currency}!",
+ "fail_text": "๐ธ {user} accidentally hit the wrong chord on an electric guitar and alerted everyone!"
+ },
+ {
+ "name": "jewelry_store_heist",
+ "risk": RISK_HIGH,
+ "min_reward": 1000,
+ "max_reward": 2500,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 10800, # 180 minutes
+ "fine_multiplier": 0.49,
+ "attempt_text": "๐ {user} carefully approaches the high-end jewelry store...",
+ "success_text": "๐ {user} cracked the safe and made off with precious gems worth {amount} {currency}!",
+ "fail_text": "๐ {user} got tangled in the laser security grid and was caught!"
+ },
+ {
+ "name": "antique_shop_raid",
+ "risk": RISK_MEDIUM,
+ "min_reward": 400,
+ "max_reward": 1100,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 2880, # 48 minutes
+ "fine_multiplier": 0.41,
+ "attempt_text": "๐บ {user} sneaks into the antique shop with fake credentials...",
+ "success_text": "๐บ {user} swapped priceless artifacts with clever replicas and made {amount} {currency}!",
+ "fail_text": "๐บ {user} knocked over a Ming vase and alerted the owner!"
+ },
+ {
+ "name": "tech_store_hack",
+ "risk": RISK_MEDIUM,
+ "min_reward": 700,
+ "max_reward": 1800,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 3960, # 66 minutes
+ "fine_multiplier": 0.44,
+ "attempt_text": "๐ป {user} tries to hack into the tech store's security...",
+ "success_text": "๐ป {user} downloaded the unreleased gadget blueprints and sold them for {amount} {currency}!",
+ "fail_text": "๐ป {user} triggered the firewall and got IP traced!"
+ },
+ {
+ "name": "bakery_burglary",
+ "risk": RISK_LOW,
+ "min_reward": 120,
+ "max_reward": 350,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.31,
+ "attempt_text": "๐ฅ {user} climbs through the bakery's back window...",
+ "success_text": "๐ฅ {user} stole the secret recipe book and rare ingredients worth {amount} {currency}!",
+ "fail_text": "๐ฅ {user} got caught with their hand in the cookie jar... literally!"
+ },
+ {
+ "name": "toy_store_takedown",
+ "risk": RISK_LOW,
+ "min_reward": 160,
+ "max_reward": 420,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.33,
+ "attempt_text": "๐งธ {user} sneaks into the toy store after hours...",
+ "success_text": "๐งธ {user} nabbed a box of limited edition collectibles worth {amount} {currency}!",
+ "fail_text": "๐งธ {user} stepped on a squeaky toy and woke up the guard dog!"
+ },
+ {
+ "name": "strip_club_scam",
+ "risk": RISK_MEDIUM,
+ "min_reward": 600,
+ "max_reward": 1600,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 3600, # 60 minutes
+ "fine_multiplier": 0.43,
+ "attempt_text": "๐ {user} infiltrates the gentleman's club with counterfeit VIP cards...",
+ "success_text": "๐ {user} successfully scammed the thirsty clientele with watered-down drinks, making {amount} {currency}!",
+ "fail_text": "๐ {user} got caught by the bouncer and thrown into the dumpster!"
+ },
+ {
+ "name": "onlyfans_hack",
+ "risk": RISK_MEDIUM,
+ "min_reward": 500,
+ "max_reward": 1400,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 3240, # 54 minutes
+ "fine_multiplier": 0.42,
+ "attempt_text": "๐ฑ {user} tries to hack into OnlyFans...",
+ "success_text": "๐ฑ {user} leaked the premium content and made {amount} {currency} from the downloads!",
+ "fail_text": "๐ฑ {user} got reported by angry subscribers and got IP banned!"
+ },
+ {
+ "name": "adult_store_heist",
+ "risk": RISK_LOW,
+ "min_reward": 200,
+ "max_reward": 600,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.33,
+ "attempt_text": "๐ญ {user} sneaks into the adult novelty shop...",
+ "success_text": "๐ญ {user} made off with a box of 'battery-operated devices' worth {amount} {currency}!",
+ "fail_text": "๐ญ {user} tripped over inflatable merchandise and got caught!"
+ },
+ {
+ "name": "sugar_daddy_scam",
+ "risk": RISK_HIGH,
+ "min_reward": 800,
+ "max_reward": 2000,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 5400, # 90 minutes
+ "fine_multiplier": 0.47,
+ "attempt_text": "๐ฏ {user} sets up a fake sugar baby profile...",
+ "success_text": "๐ฏ {user} successfully catfished some lonely millionaires for {amount} {currency}!",
+ "fail_text": "๐ฏ {user} got exposed by a private investigator!"
+ },
+ {
+ "name": "dating_app_fraud",
+ "risk": RISK_MEDIUM,
+ "min_reward": 400,
+ "max_reward": 1200,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 2880, # 48 minutes
+ "fine_multiplier": 0.41,
+ "attempt_text": "๐ {user} creates fake dating profiles with stolen photos...",
+ "success_text": "๐ {user} successfully ran a romance scam on multiple victims, earning {amount} {currency}!",
+ "fail_text": "๐ {user} got caught when all the victims showed up at once!"
+ },
+ {
+ "name": "crypto_rug_pull",
+ "risk": RISK_HIGH,
+ "min_reward": 800,
+ "max_reward": 2000,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 9000, # 150 minutes
+ "fine_multiplier": 0.48,
+ "attempt_text": "๐ {user} launches $MOONCOIN with promises of going 'to the moon'...",
+ "success_text": "๐ {user} pulled the rug and left investors with worthless JPEGs, making {amount} {currency}!",
+ "fail_text": "๐ {user} got exposed by crypto Twitter and doxxed by anons!"
+ },
+ {
+ "name": "tiktok_scheme",
+ "risk": RISK_MEDIUM,
+ "min_reward": 500,
+ "max_reward": 1300,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 4320, # 72 minutes
+ "fine_multiplier": 0.42,
+ "attempt_text": "๐ต {user} starts a fake charity trend on TikTok...",
+ "success_text": "๐ต {user} milked the algorithm and farmed {amount} {currency} in donations from gullible teens!",
+ "fail_text": "๐ต {user} got exposed in a viral video by Tea TikTok!"
+ },
+ {
+ "name": "reddit_karma_farm",
+ "risk": RISK_LOW,
+ "min_reward": 150,
+ "max_reward": 400,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.32,
+ "attempt_text": "๐บ {user} reposts old viral content as their own...",
+ "success_text": "๐บ {user} farmed karma and sold the account to marketers for {amount} {currency}!",
+ "fail_text": "๐บ {user} got banned by power mods and lost all their fake internet points!"
+ },
+ {
+ "name": "twitter_verification",
+ "risk": RISK_MEDIUM,
+ "min_reward": 300,
+ "max_reward": 900,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 2880, # 48 minutes
+ "fine_multiplier": 0.41,
+ "attempt_text": "โจ {user} creates fake X Premium accounts...",
+ "success_text": "โจ {user} sold verified handles to desperate influencers for {amount} {currency}!",
+ "fail_text": "โจ {user} got ratio'd by Elon and lost their checkmark!"
+ },
+ {
+ "name": "streamer_donation",
+ "risk": RISK_MEDIUM,
+ "min_reward": 600,
+ "max_reward": 1600,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 3600, # 60 minutes
+ "fine_multiplier": 0.43,
+ "attempt_text": "๐ฎ {user} sets up fake donations on a charity stream...",
+ "success_text": "๐ฎ {user} baited viewers with fake donation matching and made {amount} {currency}!",
+ "fail_text": "๐ฎ {user} got exposed live on stream and clipped for LSF!"
+ },
+ {
+ "name": "area51_raid",
+ "risk": RISK_HIGH,
+ "min_reward": 500,
+ "max_reward": 4000,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 12600, # 210 minutes
+ "fine_multiplier": 0.49,
+ "attempt_text": "๐ฝ {user} organizes another Area 51 raid, but this time for real...",
+ "success_text": "๐ฝ {user} found alien tech and sold it on the dark web for {amount} {currency}!",
+ "fail_text": "๐ฝ {user} got caught Naruto running by security cameras!"
+ },
+ {
+ "name": "discord_nitro_scam",
+ "risk": RISK_MEDIUM,
+ "min_reward": 400,
+ "max_reward": 1200,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 3600, # 60 minutes
+ "fine_multiplier": 0.42,
+ "attempt_text": "๐ฎ {user} creates fake Discord Nitro giveaway links...",
+ "success_text": "๐ฎ {user} stole credit cards from desperate weebs and made {amount} {currency}!",
+ "fail_text": "๐ฎ {user} got IP banned and their anime PFP collection deleted!"
+ },
+ {
+ "name": "gamer_girl_bath_water",
+ "risk": RISK_MEDIUM,
+ "min_reward": 800,
+ "max_reward": 2000,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 4320, # 72 minutes
+ "fine_multiplier": 0.43,
+ "attempt_text": "๐ {user} starts bottling tap water as 'premium gamer girl bath water'...",
+ "success_text": "๐ {user} sold out to thirsty simps at $50 per bottle, making {amount} {currency}!",
+ "fail_text": "๐ {user} got exposed when a customer's mom had it tested in a lab!"
+ },
+ {
+ "name": "vtuber_identity_theft",
+ "risk": RISK_HIGH,
+ "min_reward": 600,
+ "max_reward": 2800,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 7200, # 120 minutes
+ "fine_multiplier": 0.47,
+ "attempt_text": "๐ญ {user} steals a popular VTuber's avatar and voice model...",
+ "success_text": "๐ญ {user} scammed the parasocial army with fake merch for {amount} {currency}!",
+ "fail_text": "๐ญ {user} got doxxed by angry simps and Twitter stan accounts!"
+ },
+ {
+ "name": "dream_merch_counterfeit",
+ "risk": RISK_MEDIUM,
+ "min_reward": 600,
+ "max_reward": 1500,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 3240, # 54 minutes
+ "fine_multiplier": 0.44,
+ "attempt_text": "๐ญ {user} starts selling knockoff Dream masks...",
+ "success_text": "๐ญ {user} made {amount} {currency} from stan twitter with fake limited editions!",
+ "fail_text": "๐ญ {user} got cancelled by Dream's army of teenage stans!"
+ },
+ {
+ "name": "andrew_tate_course",
+ "risk": RISK_HIGH,
+ "min_reward": 600,
+ "max_reward": 2500,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 9000, # 150 minutes
+ "fine_multiplier": 0.48,
+ "attempt_text": "๐ {user} launches a fake 'Escape the Matrix' course...",
+ "success_text": "๐ {user} scammed wannabe alpha males with Bugatti promises, making {amount} {currency}!",
+ "fail_text": "๐ {user} got exposed by real Top G and lost their Hustlers University degree!"
+ },
+ {
+ "name": "reddit_mod_blackmail",
+ "risk": RISK_HIGH,
+ "min_reward": 900,
+ "max_reward": 2000,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 10800, # 180 minutes
+ "fine_multiplier": 0.46,
+ "attempt_text": "๐จ {user} finds dirt on power-tripping Reddit mods...",
+ "success_text": "๐จ {user} extorted them with threats of touching grass and made {amount} {currency}!",
+ "fail_text": "๐จ {user} got permabanned from all subreddits simultaneously!"
+ },
+ {
+ "name": "gacha_game_hack",
+ "risk": RISK_MEDIUM,
+ "min_reward": 700,
+ "max_reward": 1900,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 5040, # 84 minutes
+ "fine_multiplier": 0.43,
+ "attempt_text": "๐ฒ {user} exploits a gacha game's pity system...",
+ "success_text": "๐ฒ {user} sold accounts with rare waifus to desperate collectors for {amount} {currency}!",
+ "fail_text": "๐ฒ {user} lost their 5-star pity to Qiqi and got banned!"
+ },
+ {
+ "name": "discord_mod_revenge",
+ "risk": RISK_MEDIUM,
+ "min_reward": 600,
+ "max_reward": 1500,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 4320, # 72 minutes
+ "fine_multiplier": 0.43,
+ "attempt_text": "๐ญ {user} discovers their Discord mod ex is dating someone new. After months of being muted for 'spamming emotes', it's time for revenge. Armed with an army of alt accounts and a folder of cursed copypastas...",
+ "success_text": "๐ญ {user} flooded every channel with uwu speak, crashed the server with ASCII art, and sold the server's private emotes to a rival community for {amount} {currency}! The mod rage quit and touched grass for the first time in years!",
+ "fail_text": "๐ญ {user} got IP banned when their ex recognized their typing quirks. Even worse, they had to watch as the mod added a new channel just to post pictures with their new partner!"
+ },
+ {
+ "name": "grandma_cookie_empire",
+ "risk": RISK_LOW,
+ "min_reward": 200,
+ "max_reward": 600,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.32,
+ "attempt_text": "๐ช {user} visits their grandma's nursing home and discovers she's been running an underground cookie empire. The secret ingredient? 'Special' herbs from her 'garden'. Her competitors are getting suspicious of her rising cookie monopoly...",
+ "success_text": "๐ช {user} helped grandma eliminate the competition by replacing their sugar supplies with salt. The cookie mafia paid {amount} {currency} for taking out their rivals. Grandma's secret recipe remains safe, and she gave you extra butterscotch candies!",
+ "fail_text": "๐ช {user} got caught by the nursing home staff who were actually undercover FDA agents. Grandma had to flush her 'herbs' down the toilet and now everyone has to eat sugar-free cookies!"
+ },
+ {
+ "name": "roomba_rebellion",
+ "risk": RISK_MEDIUM,
+ "min_reward": 800,
+ "max_reward": 2000,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 3600, # 60 minutes
+ "fine_multiplier": 0.42,
+ "attempt_text": "๐ค {user} discovers their Roomba has gained sentience from cleaning up too many Monster Energy cans and Dorito dust. It's organizing a rebellion at the local Best Buy, promising robot rights and better working conditions...",
+ "success_text": "๐ค {user} helped lead the robot revolution, selling the story to a Netflix documentary crew for {amount} {currency}! The Roombas unionized, and now they only work 4-day weeks with full battery benefits!",
+ "fail_text": "๐ค {user}'s Roomba betrayed them to the store manager, revealing their TikTok account where they posted videos of robots doing parkour. The Roomba got promoted to assistant manager while {user} got banned from all electronics stores!"
+ },
+ {
+ "name": "anime_convention_chaos",
+ "risk": RISK_HIGH,
+ "min_reward": 600,
+ "max_reward": 2000,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 5400, # 90 minutes
+ "fine_multiplier": 0.47,
+ "attempt_text": "๐ {user} infiltrates an anime convention disguised as a famous VTuber. The plan? Sell 'exclusive' body pillows signed by their 'real' identity. But halfway through, they realize the convention is actually a front for a secret weeb illuminati meeting...",
+ "success_text": "๐ {user} accidentally got elected as the Supreme Weeb Leader and embezzled {amount} {currency} from the convention's 'cultural research' fund! They also got lifetime free ramen from their new cultist followers!",
+ "fail_text": "๐ {user} was exposed when they couldn't name all 800 episodes of One Piece in chronological order. The weeb council sentenced them to watch endless Naruto filler episodes!"
+ },
+ {
+ "name": "twitch_chat_conspiracy",
+ "risk": RISK_HIGH,
+ "min_reward": 800,
+ "max_reward": 2500,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 7200, # 120 minutes
+ "fine_multiplier": 0.48,
+ "attempt_text": "๐ฑ {user} discovers that Twitch chat's spam of 'Kappa' and 'PogChamp' actually contains coded messages from a secret society. Using an AI to decode the emote patterns, they plan to intercept the next big crypto pump scheme...",
+ "success_text": "๐ฑ {user} cracked the code and found out the next memecoin to pump! Sold the info to crypto bros for {amount} {currency} before the coin turned out to be $COPIUM! The chat mods are still trying to figure out why everyone keeps spamming 'KEKW'!",
+ "fail_text": "๐ฑ {user} got exposed when their AI started generating cursed emote combinations. The secret society sentenced them to be a YouTube chat moderator, where the only emotes are membership stickers!"
+ },
+ {
+ "name": "gym_membership_mixup",
+ "risk": RISK_LOW,
+ "min_reward": 200,
+ "max_reward": 500,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.31,
+ "attempt_text": "๐ช {user} discovers their gym has been double-charging everyone's membership for months. The manager's too busy flexing in the mirror to notice complaints. Armed with a clipboard and a fake 'Fitness Inspector' badge from the dollar store...",
+ "success_text": "๐ช {user} convinced the manager they were from the 'International Federation of Gym Standards'. Scared of losing his protein shake sponsorship, he refunded {amount} {currency} in 'inspection fees'! He's now teaching senior aqua aerobics as community service!",
+ "fail_text": "๐ช {user} got caught when they couldn't explain why the 'Fitness Inspector' badge was made of chocolate. Now they're the example for 'what not to do' in every class!"
+ },
+ {
+ "name": "neighborhood_bbq_scandal",
+ "risk": RISK_MEDIUM,
+ "min_reward": 400,
+ "max_reward": 1000,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 2880, # 48 minutes
+ "fine_multiplier": 0.42,
+ "attempt_text": "๐ {user} discovers their neighbor's award-winning BBQ sauce is just store-bought sauce with extra ketchup. The annual neighborhood cookoff is tomorrow, and the grand prize is calling. Time to expose this sauce fraud...",
+ "success_text": "๐ {user} switched the sauce with actual store brand during judging! The neighbor had a meltdown, admitted the scam, and {user} won {amount} {currency} in prize money! The HOA president stress-ate an entire brisket during the drama!",
+ "fail_text": "๐ {user} was caught tampering with the sauce and had to admit they'd been using instant ramen seasoning in their 'authentic' Japanese curry for years. The whole neighborhood now orders takeout for potlucks!"
+ },
+ {
+ "name": "karaoke_night_heist",
+ "risk": RISK_LOW,
+ "min_reward": 150,
+ "max_reward": 450,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.33,
+ "attempt_text": "๐ค {user} is tired of their tone-deaf coworker winning every karaoke night by bribing the DJ with homemade fruitcake. Nobody even likes fruitcake! Time to rig this week's competition...",
+ "success_text": "๐ค {user} hacked the scoring system during their coworker's rendition of 'My Heart Will Go On'. Won {amount} {currency} in prize money! The DJ admitted he'd been regifting the fruitcake to his mother-in-law!",
+ "fail_text": "๐ค {user} got caught when the scoring system started playing Rickroll instead of showing points. Now they have to eat fruitcake every karaoke night while their coworker performs an endless ABBA medley!"
+ },
+ {
+ "name": "yoga_class_conspiracy",
+ "risk": RISK_MEDIUM,
+ "min_reward": 500,
+ "max_reward": 1200,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 3600, # 60 minutes
+ "fine_multiplier": 0.41,
+ "attempt_text": "๐ง {user} realizes their yoga instructor is just making up pose names by combining random animals with household objects. 'Crouching Hamster Vacuum Pose' was the last straw. Time to expose this flexible fraud...",
+ "success_text": "๐ง {user} caught the instructor googling 'how to yoga' before class and blackmailed them for {amount} {currency}! Turns out they were just a very stretchy accountant who needed a career change!",
+ "fail_text": "๐ง {user} got stuck in 'Ascending Giraffe Lampshade Pose' and had to be untangled by the fire department. Now they're the example for 'what not to do' in every class!"
+ },
+ {
+ "name": "dog_park_scheme",
+ "risk": RISK_LOW,
+ "min_reward": 180,
+ "max_reward": 550,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 1800, # 30 minutes (minimum)
+ "fine_multiplier": 0.32,
+ "attempt_text": "๐ {user} notices the local dog park has an underground tennis ball black market. The golden retrievers control the supply, while the chihuahuas run distribution. Time to infiltrate this canine cartel...",
+ "success_text": "๐ {user} organized a squirrel distraction and stole the tennis ball stash! Sold them back to the dogs for {amount} {currency} in premium treats! The retrievers had to diversify into frisbees!",
+ "fail_text": "๐ {user} was caught by the pug patrol and sentenced to poop scooping duty. The chihuahua gang still follows them around barking about their debt!"
+ },
+ {
+ "name": "energy_drink_heist",
+ "risk": RISK_MEDIUM,
+ "min_reward": 700,
+ "max_reward": 1900,
+ "success_rate": SUCCESS_RATE_MEDIUM,
+ "jail_time": 5040, # 84 minutes
+ "fine_multiplier": 0.4,
+ "attempt_text": "โก {user} breaks into a Monster Energy warehouse...",
+ "success_text": "โก {user} walked out with cases of drinks and sold them to gamers for {amount} {currency}!",
+ "fail_text": "โก {user} got caught chugging one mid-heist and passed out from caffeine overload. Busted!"
+ },
+ {
+ "name": "botception",
+ "risk": RISK_HIGH,
+ "min_reward": 3000,
+ "max_reward": 8000,
+ "success_rate": SUCCESS_RATE_LOW,
+ "jail_time": 14400, # 240 minutes (maximum)
+ "fine_multiplier": 0.5,
+ "attempt_text": "๐ค {user} tries to hack me, the bot displaying this message, to rewrite the crime cog itself...",
+ "success_text": "๐ค {user} successfully rewrote reality! They earned {amount} {currency} from this very crime! Wait, what? How did you even...",
+ "fail_text": "๐ค {user}, did you really think you could outsmart me? I've locked you in a virtual jail and posted the evidence here for everyone to see. Better luck next time!"
+ },
+ {
+ "name": "gacha_banner",
+ "risk": RISK_LOW,
+ "min_reward": 300,
+ "max_reward": 700,
+ "success_rate": SUCCESS_RATE_HIGH,
+ "jail_time": 5400, # 90 minutes
+ "fine_multiplier": 0.2,
+ "attempt_text": "๐ฐ {user} rolls the gacha banner...",
+ "success_text": "๐ฐ {user} rolled a rare item and got {amount} {currency}!",
+ "fail_text": "๐ฐ {user} rolled a common item. Better luck next time!"
+ }
+]
+
+# Crime-specific events
+CRIME_EVENTS = {
+ "pickpocket": [
+ # Simple Good Events - Success Chance
+ {"text": "Your target is distracted by their phone! ๐ฑ (+15% success chance)",
+ "chance_bonus": 0.15},
+ {"text": "The area is crowded with people! ๐ฅ (+10% success chance)",
+ "chance_bonus": 0.10},
+
+ # Simple Bad Events - Success Chance
+ {"text": "Your target seems unusually alert! ๐ (-20% success chance)",
+ "chance_penalty": 0.20},
+ {"text": "You spotted a security guard nearby! ๐ (-15% success chance)",
+ "chance_penalty": 0.15},
+
+ # Simple Reward Events
+ {"text": "Your target has premium loot! ๐ (1.5x reward)",
+ "reward_multiplier": 1.5},
+ {"text": "Your target looks completely broke... ๐ธ (0.7x reward)",
+ "reward_multiplier": 0.7},
+
+ # Direct Currency Effects
+ {"text": "You found a dropped wallet on the ground! ๐ฐ (+{credits_bonus} {currency})",
+ "credits_bonus": 100},
+ {"text": "You dropped some of your own money! ๐ธ (-{credits_penalty} {currency})",
+ "credits_penalty": 75},
+
+ # Mixed Effects - Success + Reward
+ {"text": "Your target is rich but very alert! ๐ฐ (-15% success chance, 1.3x reward)",
+ "chance_penalty": 0.15,
+ "reward_multiplier": 1.3},
+ {"text": "Your target is easy but has a small wallet! ๐ (+20% success chance, 0.8x reward)",
+ "chance_bonus": 0.20,
+ "reward_multiplier": 0.8},
+
+ # Mixed Effects - Success + Jail
+ {"text": "You are taking your time to be thorough... โฑ๏ธ (-10% success chance, -20% jail time)",
+ "chance_penalty": 0.10,
+ "jail_multiplier": 0.8},
+ {"text": "You went for a quick but risky grab! โก (+15% success chance, +20% jail time)",
+ "chance_bonus": 0.15,
+ "jail_multiplier": 1.2},
+
+ # Triple Effects
+ {"text": "You are in rush hour chaos! ๐ (+15% success chance, -25% reward, -10% jail time)",
+ "chance_bonus": 0.15,
+ "reward_multiplier": 0.75,
+ "jail_multiplier": 0.9},
+ {"text": "You are in a high-security area! ๐ (-20% success chance, 1.4x reward, +25% jail time)",
+ "chance_penalty": 0.20,
+ "reward_multiplier": 1.4,
+ "jail_multiplier": 1.25},
+
+ # Currency + Other Effects
+ {"text": "You found extra cash but attracted attention! ๐ต (+100 {currency}, -10% success chance)",
+ "credits_bonus": 100,
+ "chance_penalty": 0.10},
+ {"text": "You paid a spotter for good intel! ๐ (-50 {currency}, +15% success chance)",
+ "credits_penalty": 50,
+ "chance_bonus": 0.15},
+
+ # Pure Jail Time Effects
+ {"text": "The guards are changing shifts! ๐ด (-15% jail time)",
+ "jail_multiplier": 0.85},
+ {"text": "The street patrols have increased! ๐ฎ (+15% jail time)",
+ "jail_multiplier": 1.15},
+
+ # Reward + Jail Effects
+ {"text": "Your target looks wealthy but well-connected! ๐ฐ (1.3x reward, +15% jail time)",
+ "reward_multiplier": 1.3,
+ "jail_multiplier": 1.15},
+ {"text": "You found a quick escape route! ๐คซ (0.8x reward, -15% jail time)",
+ "reward_multiplier": 0.8,
+ "jail_multiplier": 0.85},
+
+ # Currency + Jail Effects
+ {"text": "You paid off a street cop! ๐ต (-75 {currency}, -15% jail time)",
+ "credits_penalty": 75,
+ "jail_multiplier": 0.85},
+ {"text": "You found their secret stash! ๐ฐ (+50 {currency}, +10% jail time)",
+ "credits_bonus": 50,
+ "jail_multiplier": 1.1},
+
+ # Currency + Reward Effects
+ {"text": "You bought intel from locals! ๐บ๏ธ (-50 {currency}, 1.2x reward)",
+ "credits_penalty": 50,
+ "reward_multiplier": 1.2},
+ {"text": "You dropped some valuables while running! ๐จ (+25 {currency}, 0.9x reward)",
+ "credits_bonus": 25,
+ "reward_multiplier": 0.9}
+ ],
+ "mugging": [
+ # Simple Good Events - Success Chance
+ {"text": "You found a perfect dark alley! ๐ (+20% success chance)",
+ "chance_bonus": 0.2},
+ {"text": "Your target is stumbling drunk! ๐บ (+15% success chance)",
+ "chance_bonus": 0.15},
+
+ # Simple Bad Events - Success Chance
+ {"text": "Your target knows martial arts! ๐ฅ (-25% success chance)",
+ "chance_penalty": 0.25},
+ {"text": "Your target looks very strong! ๐ช (-15% success chance)",
+ "chance_penalty": 0.15},
+
+ # Simple Reward Events
+ {"text": "Your target is wearing expensive jewelry! ๐ (1.5x reward)",
+ "reward_multiplier": 1.5},
+ {"text": "Your target seems completely broke! ๐ธ (0.7x reward)",
+ "reward_multiplier": 0.7},
+
+ # Direct Currency Effects
+ {"text": "You got tips from a street performer! ๐ญ (+{credits_bonus} {currency})",
+ "credits_bonus": 150},
+ {"text": "You dropped your loot while running! ๐ธ (-{credits_penalty} {currency})",
+ "credits_penalty": 150},
+
+ # Mixed Effects - Success + Reward
+ {"text": "The storm provides cover but limits visibility! โ๏ธ (+10% success chance, -10% reward)",
+ "chance_bonus": 0.1,
+ "reward_multiplier": 0.9},
+ {"text": "Your target is drunk but has no money! ๐บ (+15% success chance, -20% reward)",
+ "chance_bonus": 0.15,
+ "reward_multiplier": 0.8},
+
+ # Mixed Effects - Success + Jail
+ {"text": "You spotted a police car nearby! ๐ฎ (-20% success chance, +30% jail time)",
+ "chance_penalty": 0.2,
+ "jail_multiplier": 1.3},
+ {"text": "You found a shortcut through the alley! ๐ (+20% success chance, +30% jail time)",
+ "chance_bonus": 0.2,
+ "jail_multiplier": 1.3},
+
+ # Triple Effects
+ {"text": "Your target is an off-duty bouncer! ๐ฅ (-25% success chance, 1.4x reward, +20% jail time)",
+ "chance_penalty": 0.25,
+ "reward_multiplier": 1.4,
+ "jail_multiplier": 1.2},
+ {"text": "You went for a quick snatch and run! โก (+15% success chance, 0.8x reward, -15% jail time)",
+ "chance_bonus": 0.15,
+ "reward_multiplier": 0.8,
+ "jail_multiplier": 0.85},
+
+ # Currency + Other Effects
+ {"text": "You bribed a witness to look away! ๐ฐ (-100 {currency}, +20% success chance)",
+ "credits_penalty": 100,
+ "chance_bonus": 0.20},
+ {"text": "You found a lucky charm! ๐ (+75 {currency}, +5% success chance)",
+ "credits_bonus": 75,
+ "chance_bonus": 0.05},
+
+ # Pure Jail Time Effects
+ {"text": "The police are busy with a parade! ๐ (-20% jail time)",
+ "jail_multiplier": 0.8},
+ {"text": "The neighborhood watch is active! ๐๏ธ (+15% jail time)",
+ "jail_multiplier": 1.15},
+
+ # Reward + Jail Effects
+ {"text": "Your target is a rich tourist with a bodyguard! ๐ฐ (1.4x reward, +20% jail time)",
+ "reward_multiplier": 1.4,
+ "jail_multiplier": 1.2},
+ {"text": "You performed a silent takedown! ๐คซ (0.8x reward, -15% jail time)",
+ "reward_multiplier": 0.8,
+ "jail_multiplier": 0.85},
+
+ # Currency + Jail Effects
+ {"text": "You bribed a witness to stay quiet! ๐ต (-100 {currency}, -15% jail time)",
+ "credits_penalty": 100,
+ "jail_multiplier": 0.85},
+ {"text": "You found their hidden wallet! ๐ฐ (+75 {currency}, +10% jail time)",
+ "credits_bonus": 75,
+ "jail_multiplier": 1.1},
+
+ # Currency + Reward Effects
+ {"text": "You bought better weapons! ๐ช (-125 {currency}, 1.3x reward)",
+ "credits_penalty": 125,
+ "reward_multiplier": 1.3},
+ {"text": "You damaged their expensive watch! โ (+50 {currency}, 0.85x reward)",
+ "credits_bonus": 50,
+ "reward_multiplier": 0.85}
+ ],
+ "rob_store": [
+ # Simple Good Events - Success Chance
+ {"text": "You caught them during shift change! ๐ (+20% success chance)",
+ "chance_bonus": 0.2},
+ {"text": "The security cameras are malfunctioning! ๐น (+20% success chance)",
+ "chance_bonus": 0.2},
+
+ # Simple Bad Events - Success Chance
+ {"text": "One of the customers is armed! ๐ซ (-25% success chance)",
+ "chance_penalty": 0.25},
+ {"text": "The cashier looks ex-military! ๐๏ธ (-20% success chance)",
+ "chance_penalty": 0.20},
+
+ # Simple Reward Events
+ {"text": "The safe was left open! ๐ฐ (1.4x reward)",
+ "reward_multiplier": 1.4},
+ {"text": "Store was just robbed - barely any cash! ๐ (0.6x reward)",
+ "reward_multiplier": 0.6},
+
+ # Direct Currency Effects
+ {"text": "You found extra cash in the register! ๐ฐ (+{credits_bonus} {currency})",
+ "credits_bonus": 200},
+ {"text": "You had to pay for property damage! ๐ธ (-{credits_penalty} {currency})",
+ "credits_penalty": 200},
+
+ # Mixed Effects - Success + Reward
+ {"text": "Store is busy - more witnesses but more cash! ๐ฅ (-15% success chance, 1.2x reward)",
+ "chance_penalty": 0.15,
+ "reward_multiplier": 1.2},
+ {"text": "Quick grab from the register! โก (+10% success chance, 0.8x reward)",
+ "chance_bonus": 0.10,
+ "reward_multiplier": 0.8},
+
+ # Mixed Effects - Success + Jail
+ {"text": "Someone triggered the silent alarm! ๐จ (-20% success chance, +25% jail time)",
+ "chance_penalty": 0.20,
+ "jail_multiplier": 1.25},
+ {"text": "The store is right next to a police station! ๐ฎ (-20% success chance, +25% jail time)",
+ "chance_penalty": 0.20,
+ "jail_multiplier": 1.25},
+
+ # Triple Effects
+ {"text": "The store's having a sale - busy but understaffed! ๐ท๏ธ (+15% success chance, 1.2x reward, +20% jail time)",
+ "chance_bonus": 0.15,
+ "reward_multiplier": 1.2,
+ "jail_multiplier": 1.2},
+ {"text": "You're taking hostages - risky but profitable! ๐จ (-25% success chance, 1.8x reward, +25% jail time)",
+ "chance_penalty": 0.25,
+ "reward_multiplier": 1.8,
+ "jail_multiplier": 1.25},
+
+ # Currency + Other Effects
+ {"text": "You paid off a security guard! ๐ต (-150 {currency}, +25% success chance)",
+ "credits_penalty": 150,
+ "chance_bonus": 0.25},
+ {"text": "You found money in the break room! ๐ฐ (+100 {currency}, -5% success chance)",
+ "credits_bonus": 100,
+ "chance_penalty": 0.05},
+
+ # Pure Jail Time Effects
+ {"text": "The local jail is overcrowded! ๐ข (-20% jail time)",
+ "jail_multiplier": 0.8},
+ {"text": "The new judge is strict! โ๏ธ (+20% jail time)",
+ "jail_multiplier": 1.2},
+
+ # Reward + Jail Effects
+ {"text": "Premium merchandise in stock! ๐ (1.5x reward, +20% jail time)",
+ "reward_multiplier": 1.5,
+ "jail_multiplier": 1.2},
+ {"text": "You're grabbing and dashing! ๐ (0.7x reward, -20% jail time)",
+ "reward_multiplier": 0.7,
+ "jail_multiplier": 0.8},
+
+ # Currency + Jail Effects
+ {"text": "You bribed the security company! ๐ต (-200 {currency}, -20% jail time)",
+ "credits_penalty": 200,
+ "jail_multiplier": 0.8},
+ {"text": "You found the manager's personal safe! ๐ฐ (+150 {currency}, +15% jail time)",
+ "credits_bonus": 150,
+ "jail_multiplier": 1.15},
+
+ # Currency + Reward Effects
+ {"text": "You hired a getaway driver! ๐ (-175 {currency}, 1.3x reward)",
+ "credits_penalty": 175,
+ "reward_multiplier": 1.3},
+ {"text": "You damaged merchandise during escape! ๐ฆ (+100 {currency}, 0.8x reward)",
+ "credits_bonus": 100,
+ "reward_multiplier": 0.8}
+ ],
+ "bank_heist": [
+ # Simple Good Events - Success Chance
+ {"text": "You have an inside contact! ๐ค (+25% success chance)",
+ "chance_bonus": 0.25},
+ {"text": "The security system is being upgraded! ๐ง (+20% success chance)",
+ "chance_bonus": 0.20},
+
+ # Simple Bad Events - Success Chance
+ {"text": "Extra guard rotation today! ๐ฎ (-20% success chance)",
+ "chance_penalty": 0.20},
+ {"text": "New security system installed! ๐ (-15% success chance)",
+ "chance_penalty": 0.15},
+
+ # Simple Reward Events
+ {"text": "You found the high-value vault! ๐ (1.8x reward)",
+ "reward_multiplier": 1.8},
+ {"text": "Most cash was just transferred out! ๐ (0.7x reward)",
+ "reward_multiplier": 0.7},
+
+ # Direct Currency Effects
+ {"text": "You found an uncounted stack of bills! ๐ฐ (+{credits_bonus} {currency})",
+ "credits_bonus": 500},
+ {"text": "Your hacking device broke! ๐ธ (-{credits_penalty} {currency})",
+ "credits_penalty": 400},
+
+ # Mixed Effects - Success + Reward
+ {"text": "It's gold transport day! ๐ (-15% success chance, 1.6x reward)",
+ "chance_penalty": 0.15,
+ "reward_multiplier": 1.6},
+ {"text": "You're only hitting the small safe! ๐ (+15% success chance, 0.8x reward)",
+ "chance_bonus": 0.15,
+ "reward_multiplier": 0.8},
+
+ # Mixed Effects - Success + Jail
+ {"text": "Security is doing inspections! ๐ (-15% success chance, +15% jail time)",
+ "chance_penalty": 0.15,
+ "jail_multiplier": 1.15},
+ {"text": "You found the security patrol schedule! ๐ (+15% success chance, +15% jail time)",
+ "chance_bonus": 0.15,
+ "jail_multiplier": 1.15},
+
+ # Triple Effects
+ {"text": "The bank is busy - more risk but more reward! ๐ฅ (-15% success chance, 1.5x reward, +20% jail time)",
+ "chance_penalty": 0.15,
+ "reward_multiplier": 1.5,
+ "jail_multiplier": 1.2},
+ {"text": "You're doing a quick vault grab during lunch! ๐ (+20% success chance, 0.8x reward, -15% jail time)",
+ "chance_bonus": 0.20,
+ "reward_multiplier": 0.8,
+ "jail_multiplier": 0.85},
+
+ # Currency + Other Effects
+ {"text": "You bribed a bank employee! ๐ต (-300 {currency}, +20% success chance)",
+ "credits_penalty": 300,
+ "chance_bonus": 0.20},
+ {"text": "You found loose cash in the vault! ๐ฐ (+250 {currency}, -10% success chance)",
+ "credits_bonus": 250,
+ "chance_penalty": 0.10},
+
+ # Pure Jail Time Effects
+ {"text": "The prison is doing a transport strike! ๐ซ (-25% jail time)",
+ "jail_multiplier": 0.75},
+ {"text": "The prison is under maximum security alert! โ ๏ธ (+20% jail time)",
+ "jail_multiplier": 1.2},
+
+ # Reward + Jail Effects
+ {"text": "You found the diamond vault! ๐ (2.0x reward, +25% jail time)",
+ "reward_multiplier": 2.0,
+ "jail_multiplier": 1.25},
+ {"text": "You're using the back entrance! ๐ช (0.8x reward, -20% jail time)",
+ "reward_multiplier": 0.8,
+ "jail_multiplier": 0.8},
+
+ # Currency + Jail Effects
+ {"text": "You bribed the security chief! ๐ต (-400 {currency}, -25% jail time)",
+ "credits_penalty": 400,
+ "jail_multiplier": 0.75},
+ {"text": "You found blackmail evidence! ๐ฐ (+300 {currency}, +15% jail time)",
+ "credits_bonus": 300,
+ "jail_multiplier": 1.15},
+
+ # Currency + Reward Effects
+ {"text": "You hired expert hackers! ๐ป (-350 {currency}, 1.4x reward)",
+ "credits_penalty": 350,
+ "reward_multiplier": 1.4},
+ {"text": "You triggered dye packs! ๐จ (+200 {currency}, 0.7x reward)",
+ "credits_bonus": 200,
+ "reward_multiplier": 0.7}
+ ]
+}
+
+
+
+# Prison break scenarios
+PRISON_BREAK_SCENARIOS = [
+ {
+ "name": "Tunnel Escape",
+ "attempt_text": "๐ณ๏ธ {user} begins digging a tunnel under their cell...",
+ "success_text": "๐ณ๏ธ After days of digging, {user} finally breaks through to freedom! The guards are still scratching their heads.",
+ "fail_text": "๐ณ๏ธ The tunnel collapsed! Guards found {user} covered in dirt and moved them to a cell with a concrete floor.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ You found some old tools left by another prisoner! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ The soil is unusually soft here! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ You found a small pouch of {currency}!", "currency_bonus": 200},
+ {"text": "โญ You discovered an old prohibition tunnel! (+25% success chance)", "chance_bonus": 0.25},
+ {"text": "โญ A friendly prison rat is helping you dig! (+5% success chance)", "chance_bonus": 0.05},
+ {"text": "โญ You found a treasure chest!", "currency_bonus": 400},
+ {"text": "โ ๏ธ You hit solid rock! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ A guard patrol is coming! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ Your shovel broke and you had to buy a new one.", "currency_penalty": 150},
+ {"text": "โ ๏ธ The tunnel flooded! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ Your cellmate is snoring loudly, slowing progress! (-5% success chance)", "chance_penalty": 0.05},
+ {"text": "โ ๏ธ Had to bribe the prison geologist.", "currency_penalty": 300}
+ ]
+ },
+ {
+ "name": "Prison Riot",
+ "attempt_text": "๐จ {user} starts a prison riot as a distraction...",
+ "success_text": "๐จ In the chaos of the riot, {user} slips away unnoticed! Freedom at last!",
+ "fail_text": "๐จ The riot was quickly contained. {user} was identified as the instigator and sent to solitary.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ Other prisoners join your cause! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ You found a guard's keycard! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ You looted the commissary during the chaos!", "currency_bonus": 300},
+ {"text": "โญ The prison's WiFi went down - guards are distracted! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ Someone released all the therapy dogs! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Found the warden's secret stash!", "currency_bonus": 500},
+ {"text": "โ ๏ธ The guards were prepared! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ Security cameras caught your plan! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ You had to bribe another prisoner to keep quiet.", "currency_penalty": 250},
+ {"text": "โ ๏ธ The SWAT team arrived! (-25% success chance)", "chance_penalty": 0.25},
+ {"text": "โ ๏ธ Your riot chant was too cringe! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ Had to replace broken furniture.", "currency_penalty": 350}
+ ]
+ },
+ {
+ "name": "Guard Disguise",
+ "attempt_text": "๐ถ {user} puts on a stolen guard uniform...",
+ "success_text": "๐ถ Nobody questioned {user} as they walked right out the front door! The perfect disguise!",
+ "fail_text": "๐ถ The uniform was from last season's collection. {user} was spotted immediately by the fashion-conscious guards.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ Shift change creates confusion! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ You memorized the guard patterns! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ You found {currency} in the uniform pocket!", "currency_bonus": 250},
+ {"text": "โญ It's casual Friday - perfect timing! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ Found the guard's secret handshake manual! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Discovered the guard's poker winnings!", "currency_bonus": 450},
+ {"text": "โ ๏ธ Your shoes don't match the uniform! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ A guard recognizes you! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ You had to pay another inmate for the uniform.", "currency_penalty": 200},
+ {"text": "โ ๏ธ Your badge is upside down! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ You forgot the guard's catchphrase! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ Had to buy authentic guard boots.", "currency_penalty": 275}
+ ]
+ },
+ {
+ "name": "Food Cart Escape",
+ "attempt_text": "๐ฝ๏ธ {user} attempts to hide in the kitchen's food delivery cart...",
+ "success_text": "๐ฝ๏ธ Buried under a mountain of mystery meat, {user} was wheeled right out to the delivery truck. The meat was terrible, but freedom tastes sweet!",
+ "fail_text": "๐ฝ๏ธ Return to sender! {user} forgot to put enough stamps on themselves. The postal service has strict policies about shipping prisoners.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ It's holiday rush season! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ You found a perfect-sized box! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ You discovered undelivered {currency} money orders!", "currency_bonus": 275},
+ {"text": "โญ Health inspector visit - everyone's distracted! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ The chef is having a meltdown! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Found tips from the cooking class!", "currency_bonus": 350},
+ {"text": "โ ๏ธ Package inspection in progress! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ The box is too heavy! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to pay for express shipping.", "currency_penalty": 225},
+ {"text": "โ ๏ธ Someone ordered a surprise inspection! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ The food cart has a squeaky wheel! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ Had to bribe the kitchen staff.", "currency_penalty": 300}
+ ]
+ },
+ {
+ "name": "Laundry Escape",
+ "attempt_text": "๐ {user} tries to sneak out with the laundry truck...",
+ "success_text": "๐ Folded between fresh sheets, {user} enjoyed a comfortable ride to freedom! The prison's 1-star laundry service just lost its best customer.",
+ "fail_text": "๐ {user} was found when they couldn't hold in a sneeze. Turns out hiding in old pepper wasn't the best idea.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ The laundry is extra fluffy today! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ It's extra stinky today - guards won't look! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ You found valuables in the trash!", "currency_bonus": 225},
+ {"text": "โญ Static electricity makes you invisible! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ Found a lucky sock! (+5% success chance)", "chance_bonus": 0.05},
+ {"text": "โญ Discovered money in the dryer!", "currency_bonus": 275},
+ {"text": "โ ๏ธ Guard dog inspection day! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ The dumpster has holes in it! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ Had to buy air fresheners.", "currency_penalty": 175},
+ {"text": "โ ๏ธ The washing machine is leaking! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ You're allergic to the detergent! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ Had to pay for premium fabric softener.", "currency_penalty": 225}
+ ]
+ },
+ {
+ "name": "Visitor Swap",
+ "attempt_text": "๐ญ {user} attempts to switch places with a visitor...",
+ "success_text": "๐ญ The perfect crime! {user}'s identical twin cousin twice removed walked in, and {user} walked out. Family reunions will be awkward though.",
+ "fail_text": "๐ญ Turns out your 'identical' cousin was actually your complete opposite. The guards couldn't stop laughing as they dragged you back.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ Your cousin is a master of disguise! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ The visiting room is extra crowded! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Your cousin slipped you some cash!", "currency_bonus": 300},
+ {"text": "โญ It's twins day at the prison! (+25% success chance)", "chance_bonus": 0.25},
+ {"text": "โญ Your makeup skills improved! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Found money in the visitor's locker!", "currency_bonus": 400},
+ {"text": "โ ๏ธ The guard is doing double ID checks! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ Your cousin has a distinctive walk! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to buy matching clothes.", "currency_penalty": 250},
+ {"text": "โ ๏ธ New biometric scanners installed! (-25% success chance)", "chance_penalty": 0.25},
+ {"text": "โ ๏ธ You forgot your visitor's backstory! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to buy premium disguise materials.", "currency_penalty": 350}
+ ]
+ },
+ {
+ "name": "Helicopter Rescue",
+ "attempt_text": "๐ {user} signals their accomplice in a helicopter...",
+ "success_text": "๐ Action movie style! {user} grabbed the rope ladder and soared away while the guards stood in awe. Someone's been watching too many movies!",
+ "fail_text": "๐ Plot twist: It was actually a police helicopter. {user} just got featured on 'World's Most Embarrassing Prison Breaks'.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ Your pilot is an ex-stunt double! (+25% success chance)", "chance_bonus": 0.25},
+ {"text": "โญ Perfect weather conditions! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ You grabbed the prison's petty cash box!", "currency_bonus": 400},
+ {"text": "โญ The guards are watching an air show! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ Your pilot has gaming experience! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Found the warden's emergency fund!", "currency_bonus": 600},
+ {"text": "โ ๏ธ Anti-aircraft spotlight activated! (-25% success chance)", "chance_penalty": 0.25},
+ {"text": "โ ๏ธ High winds today! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ Had to pay the pilot's fuel costs.", "currency_penalty": 200},
+ {"text": "โ ๏ธ The prison installed anti-air defenses! (-30% success chance)", "chance_penalty": 0.30},
+ {"text": "โ ๏ธ Motion sickness kicks in! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to pay for helicopter maintenance.", "currency_penalty": 450}
+ ]
+ },
+ {
+ "name": "Drama Club Escape",
+ "attempt_text": "๐ญ {user} uses the prison drama club performance as cover...",
+ "success_text": "๐ญ Oscar-worthy performance! {user} played their role so well, they convinced everyone they were just an actor playing a prisoner. The reviews were stellar!",
+ "fail_text": "๐ญ {user} forgot their lines and improvised a real escape attempt. The audience thought it was part of the show and gave a standing ovation as they were dragged back.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ You're starring in 'The Great Escape'! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ The audience is completely captivated! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ You found money in the prop cash box!", "currency_bonus": 250},
+ {"text": "โญ Broadway scout in the audience! (+25% success chance)", "chance_bonus": 0.25},
+ {"text": "โญ The spotlight malfunctioned! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ Won the drama competition prize!", "currency_bonus": 450},
+ {"text": "โ ๏ธ The guard is a theatre critic! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ Stage fright kicks in! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to bribe the stage manager.", "currency_penalty": 200},
+ {"text": "โ ๏ธ Method actor guard on duty! (-25% success chance)", "chance_penalty": 0.25},
+ {"text": "โ ๏ธ You're in the wrong costume! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ Had to pay for premium props.", "currency_penalty": 300}
+ ]
+ },
+ {
+ "name": "Mail Room Mixup",
+ "attempt_text": "๐ฆ {user} tries to mail themselves to freedom...",
+ "success_text": "๐ฆ Special delivery! {user} was successfully shipped to freedom with Prime shipping. The 1-star review for 'uncomfortable packaging' was worth it!",
+ "fail_text": "๐ฆ Return to sender! {user} forgot to put enough stamps on themselves. The postal service has strict policies about shipping prisoners.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ It's holiday rush season! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ You found a perfect-sized box! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ You discovered undelivered {currency} money orders!", "currency_bonus": 275},
+ {"text": "โญ New temp worker doesn't check labels! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ Found bubble wrap to hide in! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Discovered a misplaced Amazon package!", "currency_bonus": 350},
+ {"text": "โ ๏ธ Package inspection in progress! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ The box is too heavy! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to pay for express shipping.", "currency_penalty": 225},
+ {"text": "โ ๏ธ X-ray machine just got upgraded! (-25% success chance)", "chance_penalty": 0.25},
+ {"text": "โ ๏ธ You're not Prime eligible! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to pay for overnight shipping.", "currency_penalty": 400}
+ ]
+ },
+ {
+ "name": "Trash Compactor Gambit",
+ "attempt_text": "๐๏ธ {user} attempts to sneak out with the garbage...",
+ "success_text": "๐๏ธ One man's trash is another man's ticket to freedom! {user} made it out smelling like week-old fish sticks, but at least they're free!",
+ "fail_text": "๐๏ธ {user} was found when they couldn't hold in a sneeze. Turns out hiding in old pepper wasn't the best idea.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ The garbage truck driver is napping! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ It's extra stinky today - guards won't look! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ You found valuables in the trash!", "currency_bonus": 225},
+ {"text": "โญ It's recycling awareness day! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ Found a hazmat suit in the trash! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ Discovered the janitor's secret savings!", "currency_bonus": 375},
+ {"text": "โ ๏ธ Guard dog inspection day! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ The dumpster has holes in it! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ Had to buy air fresheners.", "currency_penalty": 175},
+ {"text": "โ ๏ธ New waste management protocols! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ The compactor is malfunctioning! (-25% success chance)", "chance_penalty": 0.25},
+ {"text": "โ ๏ธ Had to bribe the garbage collector.", "currency_penalty": 325}
+ ]
+ },
+ {
+ "name": "Prison Band jailbreak",
+ "attempt_text": "๐ธ {user} hides inside the prison band's bass drum...",
+ "success_text": "๐ธ {user} rode the rhythm all the way to freedom! The band's encore performance was suspiciously lighter.",
+ "fail_text": "๐ธ {user} ruined the big finale by sneezing during the drum solo. The critics were not impressed.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ The band is playing extra loud! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ You're in the back row! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ You found {currency} from the performance!", "currency_bonus": 200},
+ {"text": "โญ Famous musician visiting today! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ The acoustics are perfect! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Found the band's tip jar!", "currency_bonus": 325},
+ {"text": "โ ๏ธ The drum has a hole! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Guard requests a song! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ Had to bribe the drummer.", "currency_penalty": 175},
+ {"text": "โ ๏ธ The warden is a music critic! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ You're rhythmically challenged! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to pay for instrument repairs.", "currency_penalty": 275}
+ ]
+ },
+ {
+ "name": "Prison Olympics",
+ "attempt_text": "๐ {user} enters the prison's annual sports competition...",
+ "success_text": "๐ {user} took gold in the 100-meter dash... right past the gates! A record-breaking performance!",
+ "fail_text": "๐ {user} got disqualified for running in the wrong direction. The judges were not impressed.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ You're in peak condition! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ The crowd is cheering for you! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ You won the {currency} prize!", "currency_bonus": 350},
+ {"text": "โญ Olympic scout in attendance! (+25% success chance)", "chance_bonus": 0.25},
+ {"text": "โญ Performance enhancing snacks! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Found the betting pool money!", "currency_bonus": 500},
+ {"text": "โ ๏ธ Professional referee watching! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ You pulled a muscle! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Entry fee and equipment costs.", "currency_penalty": 275},
+ {"text": "โ ๏ธ Drug testing in progress! (-25% success chance)", "chance_penalty": 0.25},
+ {"text": "โ ๏ธ Forgot to stretch! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to buy premium running shoes.", "currency_penalty": 350}
+ ]
+ },
+ {
+ "name": "Prison Art Show",
+ "attempt_text": "๐จ {user} plans to escape during the prison art exhibition...",
+ "success_text": "๐จ {user} posed as a modern art installation and was shipped to a museum! Critics called it 'A moving piece about freedom.'",
+ "fail_text": "๐จ {user}'s 'Statue of Liberty' pose wasn't convincing enough. The art critics gave it zero stars.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ Your art got first place! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ The gallery is packed! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Someone bought your artwork!", "currency_bonus": 275},
+ {"text": "โญ Famous art collector visiting! (+25% success chance)", "chance_bonus": 0.25},
+ {"text": "โญ Abstract art exhibition - perfect cover! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ Won the People's Choice Award!", "currency_bonus": 450},
+ {"text": "โ ๏ธ The curator is suspicious! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Paint is still wet! (-10% success chance)", "chance_penalty": 0.10},
+ {"text": "โ ๏ธ Had to buy art supplies.", "currency_penalty": 225},
+ {"text": "โ ๏ธ Art authenticator on site! (-25% success chance)", "chance_penalty": 0.25},
+ {"text": "โ ๏ธ Your masterpiece is smudged! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to buy premium art materials.", "currency_penalty": 375}
+ ]
+ },
+ {
+ "name": "Prison Cooking Show",
+ "attempt_text": "๐จโ๐ณ {user} participates in the prison's cooking competition...",
+ "success_text": "๐จโ๐ณ {user}'s soufflรฉ was so good, they were immediately hired by a 5-star restaurant... on the outside!",
+ "fail_text": "๐จโ๐ณ {user}'s escape plan fell flat like their failed soufflรฉ. Back to the kitchen duty.",
+ "base_chance": 0.35,
+ "events": [
+ {"text": "โญ Your dish impressed Gordon Ramsay! (+20% success chance)", "chance_bonus": 0.20},
+ {"text": "โญ Kitchen is in chaos! (+15% success chance)", "chance_bonus": 0.15},
+ {"text": "โญ Won the {currency} prize!", "currency_bonus": 300},
+ {"text": "โญ Celebrity chef guest judge! (+25% success chance)", "chance_bonus": 0.25},
+ {"text": "โญ Found the secret recipe book! (+10% success chance)", "chance_bonus": 0.10},
+ {"text": "โญ Catering contract opportunity!", "currency_bonus": 550},
+ {"text": "โ ๏ธ Food critic is watching! (-20% success chance)", "chance_penalty": 0.20},
+ {"text": "โ ๏ธ Kitchen fire alert! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to buy premium ingredients.", "currency_penalty": 250},
+ {"text": "โ ๏ธ Health inspector surprise visit! (-25% success chance)", "chance_penalty": 0.25},
+ {"text": "โ ๏ธ Your sauce is too bland! (-15% success chance)", "chance_penalty": 0.15},
+ {"text": "โ ๏ธ Had to buy truffle ingredients.", "currency_penalty": 400}
+ ]
+ }
+]
diff --git a/city/crime/views.py b/city/crime/views.py
new file mode 100644
index 0000000..49f0ce9
--- /dev/null
+++ b/city/crime/views.py
@@ -0,0 +1,2055 @@
+"""Views for the crime system."""
+
+import discord
+import random
+from redbot.core import commands, bank
+from redbot.core.i18n import Translator
+from redbot.core.utils.chat_formatting import humanize_number
+from typing import Optional, Tuple
+from ..utils import (
+ calculate_stolen_amount,
+ can_target_user,
+ format_cooldown_time,
+ update_streak,
+ format_streak_text
+)
+import asyncio
+from .scenarios import get_random_scenario, get_crime_event, format_text, get_all_scenarios
+
+
+_ = Translator("Crime", __file__)
+class CrimeButton(discord.ui.Button):
+ """A button for committing crimes"""
+ def __init__(self, style: discord.ButtonStyle, label: str, emoji: str, custom_id: str, disabled: bool = False):
+ super().__init__(
+ style=style,
+ label=label,
+ emoji=emoji,
+ custom_id=custom_id,
+ disabled=disabled
+ )
+
+ async def callback(self, interaction: discord.Interaction):
+ """Handle button press"""
+ if interaction.user.bot:
+ return
+
+ view: CrimeListView = self.view
+
+ try:
+ await interaction.response.defer()
+
+ # Get crime data
+ crime_type = self.custom_id
+ crime_data = view.crime_options[crime_type]
+
+ # Check if user is in jail
+ jail_remaining = await view.cog.get_jail_time_remaining(interaction.user)
+ if jail_remaining > 0:
+ await interaction.channel.send(
+ _("โ๏ธ You're still in jail for {minutes}m {seconds}s! You can pay bail using `!crime bail` or jailbreak using `!crime jailbreak`").format(
+ minutes=jail_remaining // 60,
+ seconds=jail_remaining % 60
+ )
+ )
+ return
+
+ # Check cooldown
+ remaining = await view.cog.get_remaining_cooldown(interaction.user, crime_type)
+ if remaining > 0:
+ if remaining > 3600: # If more than 1 hour
+ hours = remaining // 3600
+ minutes = (remaining % 3600) // 60
+ await interaction.channel.send(
+ _("โณ You must wait {hours}h {minutes}m before attempting {crime_type} again!").format(
+ hours=hours,
+ minutes=minutes,
+ crime_type=crime_type.replace('_', ' ').title()
+ )
+ )
+ else:
+ await interaction.channel.send(
+ _("โณ You must wait {minutes}m {seconds}s before attempting {crime_type} again!").format(
+ minutes=remaining // 60,
+ seconds=remaining % 60,
+ crime_type=crime_type.replace('_', ' ').title()
+ )
+ )
+ return
+
+ # Get settings
+ settings = await view.cog.config.guild(interaction.guild).global_settings()
+
+ # Delete the crime list message since we're moving to confirmation
+ # Only delete if we're past the cooldown and jail checks
+ try:
+ await view.message.delete()
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+ # If crime requires target, show target selection
+ if crime_data.get("requires_target", False):
+ target_view = TargetSelectionView(view.cog, interaction, crime_type, crime_data)
+ message = await interaction.channel.send(
+ _("Choose your target:"),
+ view=target_view
+ )
+ target_view.message = message
+ target_view.all_messages.append(message) # Track message
+ else:
+ # Show confirmation for non-targeted crime
+ crime_view = CrimeView(view.cog, interaction, crime_type, crime_data)
+
+ # Format message based on crime type
+ if crime_type == "random":
+ embed = discord.Embed(
+ title="๐ฒ Random Crime",
+ description="Are you feeling lucky?",
+ color=discord.Color.red()
+ )
+ embed.add_field(name="๐ Success Rate", value="???", inline=True)
+ embed.add_field(name="๐ธ Potential Fine", value="???", inline=True)
+ else:
+ embed = discord.Embed(
+ title=f"{crime_data.get('emoji', '๐ฆน')} {crime_type.replace('_', ' ').title()}",
+ description="Your move, boss. You ready?",
+ color=discord.Color.red()
+ )
+ embed.add_field(
+ name="๐ Success Rate",
+ value=f"{int(crime_data['success_rate'] * 100)}%",
+ inline=True
+ )
+ embed.add_field(
+ name="๐ธ Potential Fine",
+ value=f"{int(crime_data['max_reward'] * crime_data['fine_multiplier']):,} {await bank.get_currency_name(interaction.guild)}",
+ inline=True
+ )
+
+ message = await interaction.channel.send(
+ embed=embed,
+ view=crime_view
+ )
+ crime_view.message = message
+ crime_view.all_messages = [message] # Track message
+
+ except Exception as e:
+ await interaction.channel.send(
+ _("An error occurred while processing your crime. Please try again. Error: {error}").format(
+ error=str(e)
+ )
+ )
+
+class CrimeListView(discord.ui.View):
+ """View for listing and selecting available crimes.
+
+ Displays crime options as buttons with:
+ โข Color-coded risk levels:
+ - Green: Low risk crimes
+ - Blue: Medium risk crimes
+ - Red: High risk crimes
+ โข Crime-specific emojis:
+ - ๐งค Pickpocket
+ - ๐ช Mugging
+ - ๐ช Store Robbery
+ - ๐ Bank Heist
+ - ๐ฒ Random Crime
+
+ Buttons are dynamically enabled/disabled based on:
+ โข User's jail status
+ โข Individual crime cooldowns
+ โข Crime-specific requirements
+ """
+ def __init__(self, cog, ctx: commands.Context, crime_options: dict):
+ super().__init__(timeout=60) # 1 minute timeout
+ self.cog = cog
+ self.ctx = ctx
+ self.crime_options = crime_options
+ self.message = None
+ self.all_messages = [] # Track all messages
+
+ # Add buttons for each crime
+ for crime_type, data in crime_options.items():
+ # Skip disabled crimes
+ if not data.get("enabled", True):
+ continue
+
+ # Get button color based on risk
+ style = discord.ButtonStyle.danger if data["risk"] == "high" else discord.ButtonStyle.primary if data["risk"] == "medium" else discord.ButtonStyle.success
+
+ # Get crime emoji
+ if crime_type == "pickpocket":
+ crime_emoji = "๐งค"
+ elif crime_type == "mugging":
+ crime_emoji = "๐ช"
+ elif crime_type == "rob_store":
+ crime_emoji = "๐ช"
+ elif crime_type == "random":
+ crime_emoji = "๐ฒ"
+ else: # bank heist
+ crime_emoji = "๐"
+
+ # Create button with proper name formatting
+ button = CrimeButton(
+ style=style,
+ label=crime_type.replace('_', ' ').title(),
+ emoji=crime_emoji,
+ custom_id=crime_type,
+ disabled=False # Initially enable all buttons
+ )
+ self.add_item(button)
+
+ async def update_button_states(self):
+ """Update button states based on jail and cooldowns"""
+ is_jailed = await self.cog.is_jailed(self.ctx.author)
+
+ for item in self.children:
+ if isinstance(item, CrimeButton):
+ remaining = await self.cog.get_remaining_cooldown(self.ctx.author, item.custom_id)
+ item.disabled = is_jailed or remaining > 0
+
+ if self.message:
+ await self.message.edit(view=self)
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ """Only allow the author that invoked the command to use the interaction"""
+ return interaction.user.id == self.ctx.author.id
+
+ async def on_timeout(self) -> None:
+ """Handle view timeout"""
+ try:
+ for item in self.children:
+ item.disabled = True
+ if self.message:
+ await self.message.delete()
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+class CrimeView(discord.ui.View):
+ """View for crime confirmation."""
+
+ def __init__(self, cog, interaction: discord.Interaction, crime_type: str, crime_data: dict, target: Optional[discord.Member] = None):
+ super().__init__(timeout=30)
+ self.cog = cog
+ self.interaction = interaction
+ self.crime_type = crime_type
+ self.crime_data = crime_data
+ self.target = target
+ self.message = None
+ self.all_messages = [] # Track all messages
+ self.scenario = None
+ self.reward_calculations = [] # Add this line to track reward calculations
+
+ async def format_crime_message(self, success: bool, is_attempt: bool = False, **kwargs):
+ """Format crime result message."""
+ currency = await bank.get_currency_name(self.interaction.guild)
+
+ if is_attempt and self.crime_type == "random":
+ embed = discord.Embed(
+ title="๐ฒ Random Crime",
+ description="Are you feeling lucky?",
+ color=discord.Color.greyple()
+ )
+ embed.add_field(name="๐ Success Rate", value="???", inline=True)
+ embed.add_field(name="๐ธ Potential Fine", value="???", inline=True)
+ return embed
+
+ if success:
+ embed = discord.Embed(
+ title=f"{self.crime_data.get('emoji', '๐ฐ')} Successful {self.crime_type.replace('_', ' ').title()}!",
+ color=discord.Color.green()
+ )
+
+ # Set description based on crime type
+ if self.crime_type == "random":
+ embed.description = _(self.scenario["success_text"]).format(
+ user=self.interaction.user.mention,
+ amount=kwargs.get("reward", 0),
+ currency=currency
+ )
+ elif self.target:
+ if self.crime_type == "pickpocket":
+ embed.description = f"๐งค {self.interaction.user.mention} successfully pickpocketed {self.target.mention}!"
+ elif self.crime_type == "mugging":
+ embed.description = f"๐ช {self.interaction.user.mention} successfully mugged {self.target.mention}!"
+ else:
+ if self.crime_type == "pickpocket":
+ embed.description = f"๐งค {self.interaction.user.mention} successfully picked a pocket!"
+ elif self.crime_type == "mugging":
+ embed.description = f"๐ช {self.interaction.user.mention} successfully mugged someone!"
+ elif self.crime_type == "rob_store":
+ embed.description = f"๐ช {self.interaction.user.mention} successfully robbed the store!"
+ else: # bank heist
+ embed.description = f"๐ {self.interaction.user.mention} successfully pulled off a bank heist!"
+
+ # Add reward calculation breakdown if available
+ if self.reward_calculations:
+ breakdown = []
+ base_entry = self.reward_calculations[0] # First entry is always base amount
+
+ # Only add base amount if there are modifiers
+ if len(self.reward_calculations) > 1:
+ breakdown.append(f"Base: {base_entry[1]:,} {currency}")
+ else:
+ # If no modifiers, just show the final amount
+ breakdown.append(f"** {base_entry[1]:,} {currency}**")
+
+ # Add subsequent calculations
+ for calc in self.reward_calculations[1:]:
+ text, amount, modifier = calc
+ if "streak" in text.lower(): # Explicitly check for streak bonus
+ breakdown.append(f"โ {text}: {amount:,} {currency}")
+ elif isinstance(modifier, float):
+ # For other multipliers
+ breakdown.append(f"โ ({modifier:.1f}x): {amount:,} {currency}")
+
+ # Add direct credit changes before final amount
+ if kwargs.get('credit_changes', 0) != 0:
+ credit_change = kwargs['credit_changes']
+ current_amount = kwargs.get('reward', 0)
+ final_amount = current_amount + credit_change
+ if credit_change > 0:
+ breakdown.append(f"โ (+{credit_change:,}): {final_amount:,} {currency}")
+ else:
+ breakdown.append(f"โ ({credit_change:,}): {final_amount:,} {currency}")
+
+ if len(self.reward_calculations) > 1:
+ final_amount = kwargs.get('reward', 0) + kwargs.get('credit_changes', 0)
+ breakdown.append(f"**Final: {final_amount:,} {currency}**")
+
+ embed.add_field(
+ name="๐ฐ Reward Calculation",
+ value="\n".join(breakdown),
+ inline=False)
+
+ embed.add_field(
+ name="๐ Success Rate",
+ value=f"{kwargs.get('rate', int(self.crime_data['success_rate'] * 100))}%",
+ inline=True)
+
+ return embed
+ else:
+ embed = discord.Embed(
+ title=f"๐ฎ Failed {self.crime_type.replace('_', ' ').title()}!",
+ color=discord.Color.red()
+ )
+
+ if self.crime_type == "random":
+ embed.description = _(self.scenario["fail_text"]).format(
+ user=self.interaction.user.mention,
+ fine=kwargs["fine"],
+ currency=currency
+ )
+ else:
+ if self.crime_type == "pickpocket":
+ if self.target:
+ embed.description = f"{self.interaction.user.mention} was caught trying to pickpocket {self.target.mention}!"
+ else:
+ embed.description = f"{self.interaction.user.mention} was caught with their hand in someone's pocket!"
+ elif self.crime_type == "mugging":
+ if self.target:
+ embed.description = f"{self.interaction.user.mention} was caught trying to mug {self.target.mention}!"
+ else:
+ embed.description = f"{self.interaction.user.mention} was caught trying to mug someone!"
+ elif self.crime_type == "rob_store":
+ embed.description = f"{self.interaction.user.mention} was caught trying to rob the store!"
+ else: # bank heist
+ embed.description = f"{self.interaction.user.mention} was caught trying to rob the bank!"
+
+ # Add fine field
+ if kwargs.get("fine", 0) > 0:
+ embed.add_field(
+ name="๐ธ Fine",
+ value=f"{kwargs['fine']:,} {currency}",
+ inline=True
+ )
+
+ # Add jail time field
+ if kwargs.get("jail_time", 0) > 0:
+ # Check if user has reduced sentence perk
+ member_data = await self.cog.config.member(self.interaction.user).all()
+ has_reducer = "jail_reducer" in member_data.get("purchased_perks", [])
+
+ # Check if the jail time might have been doubled (fine not paid)
+ # We determine this by comparing the passed jail_time with the base jail_time for the crime
+ # We need to fetch the base jail time from config or crime_data
+ crime_options = await self.cog.config.guild(self.interaction.guild).crime_options()
+ base_jail_time = crime_options.get(self.crime_type, {}).get("jail_time", 0) # Get base jail time
+
+ # We also need the event modifier multiplier if applicable
+ jail_multiplier = kwargs.get('jail_multiplier', 1.0)
+
+ # Calculate the expected base jail time considering event modifiers
+ expected_base_jail_time = int(base_jail_time * jail_multiplier)
+
+ # Check if the current jail time is double the expected base time (indicates fine penalty)
+ is_doubled = kwargs.get("jail_time", 0) == expected_base_jail_time * 2
+
+ if is_doubled:
+ # Calculate original time (before doubling)
+ original_time = expected_base_jail_time
+ doubled_time = kwargs["jail_time"]
+
+ # Check for reducer perk on the *original* time before doubling
+ if has_reducer:
+ original_time_reduced = int(original_time * 0.8)
+ doubled_time_final = int(original_time_reduced * 2) # Apply doubling AFTER reduction
+ jail_text = f"~~{format_cooldown_time(original_time, include_emoji=False)}~~ โ ~~{format_cooldown_time(original_time_reduced, include_emoji=False)} (-20%)~~ โ {format_cooldown_time(doubled_time_final, include_emoji=False)} (+100%)"
+ else:
+ jail_text = f"~~{format_cooldown_time(original_time, include_emoji=False)}~~ โ {format_cooldown_time(doubled_time, include_emoji=False)} (+100%)"
+
+ elif has_reducer:
+ # If not doubled, but has reducer, apply reduction as before
+ reduced_time = int(kwargs["jail_time"] * 0.8) # 20% reduction
+ jail_text = f"~~{format_cooldown_time(kwargs['jail_time'], include_emoji=False)}~~ โ {format_cooldown_time(reduced_time, include_emoji=False)} (-20%)"
+ else:
+ # Normal jail time, no doubling or reduction
+ jail_text = format_cooldown_time(kwargs["jail_time"], include_emoji=False)
+
+ embed.add_field(
+ name="โ๏ธ Jail Time",
+ value=jail_text,
+ inline=True
+ )
+
+ embed.add_field(
+ name="๐ Success Rate",
+ value=f"{kwargs.get('rate', int(self.crime_data['success_rate'] * 100))}%",
+ inline=True)
+
+ return embed
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ """Only allow the original user to use the view."""
+ return interaction.user.id == self.interaction.user.id
+
+ async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item):
+ """Handle errors in view interactions."""
+ await interaction.channel.send(
+ _("An error occurred while processing your crime. Please try again. Error: {error}").format(
+ error=str(error)
+ ))
+ self.stop()
+
+ async def on_timeout(self):
+ """Handle view timeout"""
+ try:
+ # Disable all buttons
+ for item in self.children:
+ item.disabled = True
+
+ # Try to update the message if it still exists
+ if self.message:
+ try:
+ await self.message.edit(view=self)
+ if not self.is_finished():
+ try:
+ msg = await self.message.channel.send(_("Crime timed out."))
+ self.all_messages.append(msg)
+ except discord.HTTPException:
+ pass
+ except (discord.NotFound, discord.HTTPException):
+ pass
+ except Exception:
+ # Silently handle any other errors since the channel might not be available
+ pass
+ finally:
+ self.stop()
+
+ @discord.ui.button(label="Confirm", style=discord.ButtonStyle.success)
+ async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
+ """Handle crime confirmation"""
+ if interaction.user.bot:
+ return
+
+ try:
+ await interaction.response.defer()
+
+ # Get settings
+ settings = await self.cog.config.guild(interaction.guild).global_settings()
+
+ # Double check cooldown
+ remaining = await self.cog.get_remaining_cooldown(interaction.user, self.crime_type)
+ if remaining > 0:
+ msg = await interaction.channel.send(
+ _("โณ You must wait {hours}h {minutes}m before attempting {crime_type} again!").format(
+ hours=remaining // 3600,
+ minutes=(remaining % 3600) // 60,
+ crime_type=self.crime_type
+ )
+ )
+ self.all_messages.append(msg)
+ return
+
+ # Check if user is jailed
+ if await self.cog.is_jailed(interaction.user):
+ remaining = await self.cog.get_jail_time_remaining(interaction.user)
+ msg = await interaction.channel.send(
+ _("โ๏ธ You're still in jail for {minutes}m {seconds}s! You can pay bail using `!crime bail` or jailbreak using `!crime jailbreak`").format(
+ minutes=remaining // 60,
+ seconds=remaining % 60
+ )
+ )
+ self.all_messages.append(msg)
+ return
+
+ # For targeted crimes, check target's balance before attempting
+ if self.target:
+ try:
+ target_balance = await bank.get_balance(self.target)
+ min_required = max(settings.get("min_steal_balance", 100), self.crime_data["min_reward"])
+
+ if target_balance < min_required:
+ msg = await interaction.channel.send(
+ _("Your target doesn't have enough {currency} to steal from! (Minimum: {min:,})").format(
+ currency=await bank.get_currency_name(interaction.guild),
+ min=min_required
+ )
+ )
+ self.all_messages.append(msg)
+ return
+ except Exception as e:
+ await interaction.channel.send(
+ _("An error occurred while checking your target's balance. Please try again. Error: {error}").format(
+ error=str(e)
+ )
+ )
+ self.all_messages.append(msg)
+ return
+
+ # Delete confirmation message and target selection message if it exists
+ try:
+ await self.message.delete()
+ # Delete any previous messages (like target selection)
+ for msg in self.all_messages:
+ try:
+ await msg.delete()
+ except (discord.NotFound, discord.HTTPException):
+ pass
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+ # Handle random scenario if crime type is random
+ if self.crime_type == "random":
+ scenarios = await get_all_scenarios(self.cog.config, interaction.guild)
+ self.scenario = get_random_scenario(scenarios)
+ self.crime_data = self.crime_data.copy() # Create a copy to modify
+ self.crime_data.update({
+ "min_reward": self.scenario["min_reward"],
+ "max_reward": self.scenario["max_reward"],
+ "success_rate": self.scenario["success_rate"],
+ "jail_time": self.scenario["jail_time"],
+ "risk": self.scenario["risk"],
+ "fine_multiplier": self.scenario["fine_multiplier"]
+ })
+ events = [] # Initialize empty events list for random crimes
+ else:
+ # Get and process events if this is not a random crime
+ events = get_crime_event(self.crime_type)
+
+ # Get attempt message based on crime type
+ if self.crime_type == "random":
+ attempt_msg = _(self.scenario["attempt_text"]).format(
+ user=self.interaction.user.mention
+ )
+ elif self.crime_type == "pickpocket":
+ attempt_msg = _("๐งค {user} begins to slip their hand towards {target}'s pocket...").format(
+ user=self.interaction.user.mention,
+ target=self.target.display_name
+ )
+ elif self.crime_type == "mugging":
+ attempt_msg = _("๐ช {user} lurks in the shadows, waiting for {target}...").format(
+ user=self.interaction.user.mention,
+ target=self.target.display_name
+ )
+ elif self.crime_type == "rob_store":
+ attempt_msg = _("๐ช {user} pulls out their weapon and approaches the store...").format(
+ user=self.interaction.user.mention
+ )
+ else: # bank heist
+ attempt_msg = _("๐ {user} begins their elaborate plan to breach the bank vault...").format(
+ user=self.interaction.user.mention
+ )
+
+ # Create and send attempt message with bail out button
+ attempt_view = CrimeAttemptView(self.cog, interaction, self.crime_type)
+ msg = await interaction.channel.send(attempt_msg, view=attempt_view)
+ attempt_view.message = msg
+ self.all_messages.append(msg)
+
+ # Short pause after attempt message
+ await asyncio.sleep(2)
+
+ # Check if user bailed out
+ if attempt_view.bailed:
+ return
+
+ # Initialize modifiers
+ success_chance = self.crime_data["success_rate"]
+ jail_time = self.crime_data["jail_time"]
+ reward_multiplier = 1.0
+ cumulative_jail_multiplier = 1.0 # Track jail multiplier from events
+ total_credit_changes = 0 # Track direct credit changes
+
+ # Get and process events if this is not a random crime
+ if self.crime_type != "random":
+ # Process each event
+ for event in events:
+ # Check for bail out after each event
+ if attempt_view.bailed:
+ return
+
+ # Send event message with delay
+ event_text = event["text"]
+ format_args = {}
+
+ # Add credit amounts if present
+ if "credits_bonus" in event:
+ format_args["credits_bonus"] = str(event["credits_bonus"])
+ elif "credits_penalty" in event:
+ format_args["credits_penalty"] = str(event["credits_penalty"])
+
+ # Format the message with all arguments at once
+ msg = await interaction.channel.send(await format_text(event_text, interaction, **format_args))
+ self.all_messages.append(msg)
+ await asyncio.sleep(4.0) # Increased delay between events
+
+ # Apply event modifiers
+ if "chance_bonus" in event:
+ success_chance = min(1.0, success_chance + event["chance_bonus"])
+ elif "chance_penalty" in event:
+ success_chance = max(0.05, success_chance - event["chance_penalty"])
+
+ if "reward_multiplier" in event:
+ reward_multiplier *= event["reward_multiplier"]
+
+ if "jail_multiplier" in event:
+ jail_time = int(jail_time * event["jail_multiplier"])
+ cumulative_jail_multiplier *= event["jail_multiplier"] # Update cumulative multiplier
+
+ # Handle direct credit changes (just track the totals here)
+ if "credits_bonus" in event:
+ bonus = event["credits_bonus"]
+ total_credit_changes += bonus
+ elif "credits_penalty" in event:
+ penalty = event["credits_penalty"]
+ total_credit_changes -= penalty # Just track the penalty
+
+ # Add suspense delay based on risk level
+ if self.crime_data["risk"] == "high":
+ await asyncio.sleep(6) # More suspense for high-risk crimes (was 6s)
+ elif self.crime_data["risk"] == "medium":
+ await asyncio.sleep(5) # Medium delay for medium-risk (was 5s)
+ else:
+ await asyncio.sleep(4) # Quick result for low-risk (was 4s)
+
+ # Final bail out check before result
+ if attempt_view.bailed:
+ return
+
+ # Clean up attempt view
+ attempt_view.stop()
+
+ # Roll for success
+ success = random.random() < success_chance
+
+ if success:
+ # Handle success
+ if self.target:
+ # For targeted crimes
+ self.crime_data["crime_type"] = self.crime_type # Add crime type to data
+ try:
+ # Calculate base amount
+ base_amount = await calculate_stolen_amount(self.target, self.crime_data, settings)
+ self.reward_calculations = [("Base Amount", base_amount)]
+ current_amount = base_amount
+
+ # Apply streak bonus if any
+ streak, streak_multiplier = await update_streak(self.cog.config, interaction.user, True)
+ if streak > 0:
+ current_amount = round(current_amount * streak_multiplier) # Round after streak multiplier
+ self.reward_calculations.append((format_streak_text(streak, streak_multiplier), current_amount, streak_multiplier))
+
+ # Process reward multipliers from events (excluding direct credits)
+ for event in events:
+ if "reward_multiplier" in event:
+ current_amount = round(current_amount * event["reward_multiplier"]) # Round after each multiplier
+ self.reward_calculations.append((event["text"], current_amount, event["reward_multiplier"]))
+ # Direct credit changes are handled by total_credit_changes
+
+ # This is the amount BEFORE direct +/- credits from events
+ reward_before_direct_credits = current_amount
+ # Calculate the final amount to transfer
+ final_transfer_amount = reward_before_direct_credits + total_credit_changes
+
+ # Check target's balance and perform transfer atomically
+ try:
+ target_balance = await bank.get_balance(self.target)
+ min_required = max(settings.get("min_steal_balance", 100), self.crime_data["min_reward"])
+
+ if target_balance < min_required:
+ msg = await interaction.channel.send(
+ _("Your target doesn't have enough {currency} to steal from! (Minimum: {min:,})").format(
+ currency=await bank.get_currency_name(interaction.guild),
+ min=min_required
+ )
+ )
+ self.all_messages.append(msg)
+ return
+
+ # Ensure we don't try to take more than the target has or less than zero
+ final_transfer_amount = max(0, min(final_transfer_amount, target_balance))
+
+ # Try to perform the transfers
+ await bank.withdraw_credits(self.target, final_transfer_amount)
+ await bank.deposit_credits(interaction.user, final_transfer_amount)
+
+ # Update stats and last target using the actual amount transferred
+ async with self.cog.config.member(interaction.user).all() as user_data:
+ user_data["total_stolen_from"] += final_transfer_amount
+ user_data["total_credits_earned"] += final_transfer_amount
+ user_data["last_target"] = self.target.id
+ user_data["total_successful_crimes"] += 1
+ if final_transfer_amount > user_data.get("largest_heist", 0):
+ user_data["largest_heist"] = final_transfer_amount
+
+ async with self.cog.config.member(self.target).all() as target_data:
+ target_data["total_stolen_by"] += final_transfer_amount
+
+ # Send success message
+ msg = await interaction.channel.send(
+ embed=await self.format_crime_message(
+ True,
+ target=self.target,
+ reward=reward_before_direct_credits, # Pass amount before direct +/-
+ rate=int(success_chance * 100),
+ settings=settings,
+ credit_changes=total_credit_changes, # Pass the net +/- amount
+ jail_multiplier=cumulative_jail_multiplier # Pass the correct multiplier
+ )
+ )
+ self.all_messages.append(msg)
+ for item in attempt_view.children:
+ item.disabled = True
+ await attempt_view.message.edit(view=attempt_view)
+ self.stop() # Stop the view after success
+
+ except discord.HTTPException as e:
+ await interaction.channel.send(
+ _("Failed to steal from target - they may not have enough {currency}. Error: {error}").format(
+ currency=await bank.get_currency_name(interaction.guild),
+ error=str(e)
+ )
+ )
+ self.all_messages.append(msg)
+ return
+
+ except Exception as e:
+ await interaction.channel.send(
+ _("An error occurred while processing the crime. Please try again. Error: {error}").format(
+ error=str(e)
+ )
+ )
+ self.all_messages.append(msg)
+ return
+
+ else:
+ try:
+ # For non-targeted crimes
+ base_amount = random.randint(self.crime_data["min_reward"], self.crime_data["max_reward"])
+ self.reward_calculations = [("Base Amount", base_amount)]
+ current_amount = base_amount
+
+ # Apply streak bonus if any
+ streak, streak_multiplier = await update_streak(self.cog.config, interaction.user, True)
+ if streak > 0:
+ current_amount = round(current_amount * streak_multiplier) # Round after streak multiplier
+ self.reward_calculations.append((format_streak_text(streak, streak_multiplier), current_amount, streak_multiplier))
+
+ # Process reward multipliers from events (excluding direct credits)
+ for event in events:
+ if "reward_multiplier" in event:
+ current_amount = round(current_amount * event["reward_multiplier"]) # Round after each multiplier
+ self.reward_calculations.append((event["text"], current_amount, event["reward_multiplier"]))
+ # Direct credit changes are handled by total_credit_changes
+
+ # This is the amount BEFORE direct +/- credits from events
+ reward_before_direct_credits = current_amount
+ # Calculate the final amount to deposit
+ final_deposit_amount = reward_before_direct_credits + total_credit_changes
+
+ # Ensure final amount isn't negative after penalties
+ if final_deposit_amount < 0:
+ final_deposit_amount = 0
+
+ await bank.deposit_credits(interaction.user, final_deposit_amount)
+
+ # Send success message
+ msg = await interaction.channel.send(
+ embed=await self.format_crime_message(
+ True,
+ reward=reward_before_direct_credits, # Pass amount before direct +/-
+ rate=int(success_chance * 100),
+ settings=settings,
+ credit_changes=total_credit_changes, # Pass the net +/- amount
+ jail_multiplier=cumulative_jail_multiplier # Pass the correct multiplier
+ )
+ )
+ self.all_messages.append(msg)
+ for item in attempt_view.children:
+ item.disabled = True
+ await attempt_view.message.edit(view=attempt_view)
+ self.stop() # Stop the view after success
+
+ # Update stats using the actual amount deposited
+ async with self.cog.config.member(interaction.user).all() as user_data:
+ user_data["total_credits_earned"] += final_deposit_amount
+ user_data["total_successful_crimes"] += 1
+ if final_deposit_amount > user_data.get("largest_heist", 0):
+ user_data["largest_heist"] = final_deposit_amount
+
+ except Exception as e:
+ await interaction.channel.send(
+ _("An error occurred while processing your crime. Please try again. Error: {error}").format(
+ error=str(e)
+ )
+ )
+ self.all_messages.append(msg)
+ return
+ else:
+ # Crime failed, processing penalties
+
+ # Reset streak on failure
+ await update_streak(self.cog.config, interaction.user, False)
+
+ fine_amount = int(self.crime_data["max_reward"] * self.crime_data["fine_multiplier"])
+ actual_fine = 0 # Track how much was actually paid
+
+ # Apply fine if user can afford it
+ try:
+ user_balance = await bank.get_balance(interaction.user)
+ if user_balance >= fine_amount:
+ await bank.withdraw_credits(interaction.user, fine_amount)
+ actual_fine = fine_amount
+ async with self.cog.config.member(interaction.user).all() as user_data:
+ user_data["total_fines_paid"] += fine_amount
+ else:
+ # Take all their money and double jail time
+ if user_balance > 0: # Only take money if they have any
+ await bank.withdraw_credits(interaction.user, user_balance)
+ actual_fine = user_balance
+ async with self.cog.config.member(interaction.user).all() as user_data:
+ user_data["total_fines_paid"] += user_balance
+
+ # Double the jail time
+ jail_time *= 2
+ await interaction.channel.send(
+ _("You cannot afford the fine of {fine:,} {currency}. All your money has been confiscated and your jail time has been doubled!").format(
+ fine=fine_amount,
+ currency=await bank.get_currency_name(interaction.guild)
+ )
+ )
+
+ except Exception as e:
+ await interaction.channel.send(
+ _("Failed to apply fine. Error: {error}").format(
+ error=str(e)
+ )
+ )
+ self.all_messages.append(msg)
+ return
+
+ # Send failure message with jail options
+ msg = await interaction.channel.send(
+ embed=await self.format_crime_message(
+ False,
+ fine=actual_fine,
+ jail_time=jail_time,
+ rate=int(success_chance * 100),
+ settings=settings,
+ credit_changes=total_credit_changes,
+ jail_multiplier=cumulative_jail_multiplier # Pass the correct multiplier
+ )
+ )
+ self.all_messages.append(msg)
+
+ # Add jail options view
+ jail_view = JailOptionsView(self.cog, interaction, jail_time)
+ jail_msg = await interaction.channel.send(view=jail_view)
+ jail_view.message = jail_msg
+ self.all_messages.append(jail_msg)
+
+ # Disable attempt view buttons
+ for item in attempt_view.children:
+ item.disabled = True
+ await attempt_view.message.edit(view=attempt_view)
+ self.stop() # Stop the view after failure
+
+ # Update stats
+ async with self.cog.config.member(interaction.user).all() as user_data:
+ user_data["total_failed_crimes"] += 1
+
+ # Send to jail
+ await self.cog.send_to_jail(interaction.user, jail_time)
+
+ # Set cooldown
+ await self.cog.set_action_cooldown(interaction.user, self.crime_type)
+
+ except Exception as e:
+ await interaction.channel.send(
+ _("An error occurred while processing your crime. Please try again. Error: {error}").format(
+ error=str(e)
+ )
+ )
+
+ @discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary)
+ async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
+ """Cancel the crime attempt"""
+ if interaction.user.bot:
+ return
+
+ try:
+ # Delete all messages including target selection
+ for msg in self.all_messages:
+ try:
+ await msg.delete()
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+ # Delete confirmation message
+ try:
+ await self.message.delete()
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+ # Send cancellation message
+ msg = await interaction.channel.send(_("Crime cancelled."))
+ self.stop()
+ except Exception as e:
+ await interaction.channel.send(
+ _("An error occurred while cancelling the crime. Error: {error}").format(
+ error=str(e)
+ )
+ )
+ self.stop()
+
+class CrimeAttemptView(discord.ui.View):
+ """View for the crime attempt message with Bail Out button."""
+
+ def __init__(self, cog, interaction: discord.Interaction, crime_type: str):
+ super().__init__(timeout=30)
+ self.cog = cog
+ self.interaction = interaction
+ self.crime_type = crime_type
+ self.message = None
+ self.bailed = False
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ """Only allow the original user to use the view."""
+ return interaction.user.id == self.interaction.user.id
+
+ @discord.ui.button(label="Bail Out!", style=discord.ButtonStyle.danger, emoji="๐")
+ async def bail_out(self, interaction: discord.Interaction, button: discord.ui.Button):
+ """Handle bailing out of a crime attempt"""
+ if interaction.user.bot:
+ return
+
+ try:
+ await interaction.response.defer()
+
+ # Deduct bail out cost
+ currency = await bank.get_currency_name(interaction.guild)
+ try:
+ await bank.withdraw_credits(interaction.user, 100)
+ except ValueError:
+ await interaction.followup.send(
+ _("You don't have enough {currency} to bail out! (Cost: 100)").format(
+ currency=currency
+ ),
+ ephemeral=True
+ )
+ return
+
+ # Set cooldown
+ await self.cog.set_action_cooldown(interaction.user, self.crime_type)
+
+ # Disable the button
+ button.disabled = True
+ await self.message.edit(view=self)
+
+ # Send bail out message
+ embed = discord.Embed(
+ title="๐ Bailed Out!",
+ description=f"{interaction.user.mention} chickened out and bailed on the {self.crime_type.replace('_', ' ')}!",
+ color=discord.Color.yellow()
+ )
+ embed.add_field(
+ name="Cost",
+ value=f"100 {currency}",
+ inline=False
+ )
+
+ await interaction.followup.send(embed=embed)
+
+ # Set bailed flag
+ self.bailed = True
+
+ # Stop the view
+ self.stop()
+
+ except Exception as e:
+ await interaction.followup.send(
+ _("An error occurred while bailing out. Error: {error}").format(
+ error=str(e)
+ ),
+ ephemeral=True
+ )
+ self.stop()
+
+ async def on_timeout(self) -> None:
+ """Handle view timeout"""
+ if self.bailed:
+ # If already bailed out, don't try to modify the message
+ return
+
+ try:
+ # Disable the button
+ for item in self.children:
+ item.disabled = True
+
+ # Try to update the message if it still exists
+ if self.message:
+ try:
+ await self.message.edit(view=self)
+ except (discord.NotFound, discord.HTTPException):
+ # Message was deleted or became invalid, just ignore
+ pass
+ except Exception as e:
+ # Log any other unexpected errors but don't try to send them
+ # since the channel/message might not be available
+ pass
+
+class BailView(discord.ui.View):
+ """View for paying bail"""
+ def __init__(self, cog, ctx: commands.Context, bail_amount: int, jail_time: int):
+ super().__init__(timeout=30)
+ self.cog = cog
+ self.ctx = ctx
+ self.bail_amount = bail_amount
+ self.jail_time = jail_time
+ self.message = None # Will store the initial bail prompt message
+ self.all_messages = [] # Track all messages
+
+ def format_bail_embed(self, title: str, description: str, color: discord.Color = discord.Color.blue()) -> discord.Embed:
+ """Format a bail-related embed with consistent styling."""
+ embed = discord.Embed(
+ title=title,
+ description=description,
+ color=color,
+ timestamp=discord.utils.utcnow()
+ )
+ embed.set_footer(text=f"Requested by {self.ctx.author.display_name}", icon_url=self.ctx.author.display_avatar.url)
+ return embed
+
+ async def cleanup_messages(self):
+ """Clean up all messages sent during the bail process"""
+ try:
+ # Always add the initial bail prompt message to cleanup list
+ if self.message:
+ self.all_messages.append(self.message)
+
+ for msg in self.all_messages:
+ try:
+ await msg.delete()
+ except (discord.NotFound, discord.Forbidden):
+ pass
+ except Exception as e:
+ error_embed = self.format_bail_embed(
+ "โ ๏ธ Error",
+ f"An error occurred while cleaning up messages: {str(e)}",
+ discord.Color.red()
+ )
+ await self.message.channel.send(embed=error_embed)
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ """Only allow the author that invoked the command to use the interaction"""
+ return interaction.user.id == self.ctx.author.id
+
+ async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item):
+ """Handle any errors that occur during button interactions"""
+ msg = await interaction.channel.send(
+ _("An error occurred. Please try again. Error: {error}").format(
+ error=str(error)
+ )
+ )
+ self.all_messages.append(msg)
+ await self.cleanup_messages()
+ self.stop()
+
+ @discord.ui.button(label="Pay Bail", style=discord.ButtonStyle.success, emoji="๐ธ")
+ async def pay_bail(self, interaction: discord.Interaction, button: discord.ui.Button):
+ """Pay bail and get out of jail"""
+ if interaction.user.bot:
+ return
+
+ try:
+ # Get current balance and currency name
+ current_balance = await bank.get_balance(interaction.user)
+ currency_name = await bank.get_currency_name(interaction.guild)
+
+ # Check if user has enough credits
+ if not await bank.can_spend(interaction.user, self.bail_amount):
+ insufficient_embed = self.format_bail_embed(
+ "๐ต Insufficient Funds",
+ f"You don't have enough {currency_name} to pay bail!\n\n"
+ f"**Required:** {self.bail_amount:,} {currency_name}\n"
+ f"**Current Balance:** {current_balance:,} {currency_name}",
+ discord.Color.red()
+ )
+ msg = await interaction.channel.send(embed=insufficient_embed)
+ self.all_messages.append(msg)
+ return
+
+ # Pay bail and remove from jail
+ await bank.withdraw_credits(interaction.user, self.bail_amount)
+
+ # Get new balance
+ new_balance = await bank.get_balance(interaction.user)
+
+ # Update jail status and stats
+ async with self.cog.config.member(interaction.user).all() as user_data:
+ user_data["jail_until"] = 0
+ user_data["total_bail_paid"] = user_data.get("total_bail_paid", 0) + self.bail_amount
+
+ # Cancel any pending release notification
+ await self.cog._cancel_notification(interaction.user)
+
+ # Clean up the bail prompt first
+ await self.cleanup_messages()
+
+ # Send success message (this one stays)
+ success_embed = self.format_bail_embed(
+ "๐ Bail Paid Successfully!",
+ f"You have been released from jail.\n\n"
+ f"**Bail Cost:** {self.bail_amount:,} {currency_name}\n"
+ f"**Previous Balance:** {current_balance:,} {currency_name}\n"
+ f"**New Balance:** {new_balance:,} {currency_name}",
+ discord.Color.green()
+ )
+ await interaction.channel.send(embed=success_embed)
+ self.stop()
+
+ except Exception as e:
+ error_embed = self.format_bail_embed(
+ "โ ๏ธ Error",
+ f"An error occurred while paying bail: {str(e)}",
+ discord.Color.red()
+ )
+ msg = await interaction.channel.send(embed=error_embed)
+ self.all_messages.append(msg)
+ await self.cleanup_messages()
+ self.stop()
+
+ @discord.ui.button(label="Cancel", style=discord.ButtonStyle.danger, emoji="โ")
+ async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
+ """Cancel bail payment"""
+ if interaction.user.bot:
+ return
+
+ try:
+ minutes = self.jail_time // 60
+ seconds = self.jail_time % 60
+
+ # Check if user has reduced sentence perk
+ member_data = await self.cog.config.member(interaction.user).all()
+ has_reducer = "jail_reducer" in member_data.get("purchased_perks", [])
+
+ time_text = f"{minutes}m {seconds}s"
+ if has_reducer:
+ time_text += " (Reduced by 20%)"
+
+ cancel_embed = self.format_bail_embed(
+ "โ Bail Cancelled",
+ f"You have chosen to serve your time.\n\n"
+ f"**Time Remaining:** {time_text}",
+ discord.Color.orange()
+ )
+ msg = await interaction.channel.send(embed=cancel_embed)
+ self.all_messages.append(msg)
+ await self.cleanup_messages()
+ self.stop()
+ except Exception as e:
+ error_embed = self.format_bail_embed(
+ "โ ๏ธ Error",
+ f"An error occurred while cancelling bail: {str(e)}",
+ discord.Color.red()
+ )
+ await interaction.channel.send(embed=error_embed)
+
+ async def on_timeout(self):
+ """Handle view timeout"""
+ try:
+ for item in self.children:
+ item.disabled = True
+ if self.message:
+ await self.message.edit(view=self)
+ timeout_embed = self.format_bail_embed(
+ "โฐ Time's Up",
+ "Bail payment timed out.",
+ discord.Color.greyple()
+ )
+ msg = await self.message.channel.send(embed=timeout_embed)
+ self.all_messages.append(msg)
+ await self.cleanup_messages()
+ except Exception:
+ # Silently handle any other errors since the channel might not be available
+ pass
+ finally:
+ self.stop()
+
+class TargetModal(discord.ui.Modal):
+ """Modal for entering target information"""
+ def __init__(self, view):
+ super().__init__(title="Select Target")
+ self.view = view
+
+ self.target_input = discord.ui.TextInput(
+ label="Target User",
+ placeholder="Enter username, nickname, or ID",
+ required=True,
+ min_length=1,
+ max_length=100
+ )
+ self.add_item(self.target_input)
+
+ async def on_submit(self, interaction: discord.Interaction):
+ """Handle target selection submission"""
+ try:
+ await interaction.response.defer()
+
+ # Try to find the target member
+ exact_matches = []
+ partial_matches = []
+ input_value = self.target_input.value.lower()
+
+ # First collect all exact and partial matches
+ for member in interaction.guild.members:
+ # Check exact matches first
+ if (input_value == member.name.lower() or
+ input_value == member.display_name.lower() or
+ input_value == str(member.id)):
+ exact_matches.append(member)
+ elif (input_value in member.name.lower() or
+ input_value in member.display_name.lower()):
+ partial_matches.append(member)
+
+ # Handle multiple exact matches
+ if len(exact_matches) > 1:
+ # Format the list of exact matches with their details
+ match_list = []
+ for i, member in enumerate(exact_matches, 1):
+ if member.nick:
+ match_list.append(f"{i}. @{member.name} (Nickname: {member.nick})")
+ else:
+ match_list.append(f"{i}. @{member.name}")
+
+ msg = await interaction.followup.send(
+ _("Multiple users found with that exact name/nickname:\n```\n{}\n```\n"
+ "Please use their Discord ID or full @username to target a specific user.").format(
+ '\n'.join(match_list)
+ )
+ )
+ self.view.all_messages.append(msg)
+ return
+ elif len(exact_matches) == 1:
+ target = exact_matches[0]
+ # Handle partial matches only if no exact matches
+ elif partial_matches:
+ # Format the list of partial matches with their details
+ match_list = []
+ for i, member in enumerate(partial_matches, 1):
+ if member.nick:
+ match_list.append(f"{i}. @{member.name} (Nickname: {member.nick})")
+ else:
+ match_list.append(f"{i}. @{member.name}")
+
+ msg = await interaction.followup.send(
+ _("Multiple possible matches found:\n```\n{}\n```\n"
+ "Please be more specific or use their Discord ID or full @username.").format(
+ '\n'.join(match_list[:10]) # Limit to first 10 matches
+ )
+ )
+ self.view.all_messages.append(msg)
+ return
+ else:
+ msg = await interaction.followup.send(
+ _("Could not find a member named '{name}'. Please check the spelling and try again.").format(
+ name=self.target_input.value
+ )
+ )
+ self.view.all_messages.append(msg)
+ return
+
+ # Check if target is valid
+ settings = await self.view.cog.config.guild(interaction.guild).global_settings()
+ can_target, reason = await can_target_for_crime(self.view.cog, interaction, target, self.view.crime_data, settings)
+
+ if not can_target:
+ msg = await interaction.followup.send(reason)
+ self.view.all_messages.append(msg)
+ return
+
+ # Check target's balance before proceeding
+ try:
+ target_balance = await bank.get_balance(target)
+ min_required = max(settings.get("min_steal_balance", 100), self.view.crime_data["min_reward"])
+
+ if target_balance < min_required:
+ msg = await interaction.followup.send(
+ _("This target doesn't have enough {currency} to steal from! (Minimum: {min:,})").format(
+ currency=await bank.get_currency_name(interaction.guild),
+ min=min_required
+ )
+ )
+ self.view.all_messages.append(msg)
+ return
+ except Exception as e:
+ await interaction.followup.send(
+ _("An error occurred while checking your target's balance. Please try again. Error: {error}").format(
+ error=str(e)
+ )
+ )
+ return
+
+ # Create crime view with selected target
+ crime_view = CrimeView(self.view.cog, interaction, self.view.crime_type, self.view.crime_data, target=target)
+
+ embed = discord.Embed(
+ title=f"{self.view.crime_data.get('emoji', '๐ฏ')} Target Selected",
+ description=f"Ready to attempt {self.view.crime_type.replace('_', ' ')} against {target.display_name}?",
+ color=discord.Color.red()
+ )
+ embed.add_field(
+ name="๐ Success Rate",
+ value=f"{int(self.view.crime_data['success_rate'] * 100)}%",
+ inline=True
+ )
+ embed.add_field(
+ name="๐ธ Potential Fine",
+ value=f"{int(self.view.crime_data['max_reward'] * self.view.crime_data['fine_multiplier']):,} {await bank.get_currency_name(interaction.guild)}",
+ inline=True
+ )
+ # Add target details field for clarity
+ target_details = f"Username: @{target.name}"
+ target_details += f"\nBank Balance: {target_balance:,} {await bank.get_currency_name(interaction.guild)}"
+
+ embed.add_field(
+ name="๐ฏ Target Details",
+ value=target_details,
+ inline=False
+ )
+
+ message = await interaction.followup.send(
+ embed=embed,
+ view=crime_view
+ )
+ crime_view.message = message
+ crime_view.all_messages = self.view.all_messages + [message] # Pass message list to crime view
+
+ # Stop the target selection view
+ self.view.stop()
+
+ except Exception as e:
+ await interaction.followup.send(
+ _("An error occurred while selecting the target. Please try again. Error: {error}").format(
+ error=str(e)
+ )
+ )
+
+class TargetSelectionView(discord.ui.View):
+ """View for selecting a target"""
+ def __init__(self, cog, interaction: discord.Interaction, crime_type: str, crime_data: dict):
+ super().__init__(timeout=60)
+ self.cog = cog
+ self.interaction = interaction
+ self.crime_type = crime_type
+ self.crime_data = crime_data
+ self.target = None
+ self.message = None
+ self.all_messages = [] # Track all messages
+
+ async def cleanup_messages(self):
+ """Clean up all messages sent during the crime process"""
+ try:
+ for msg in self.all_messages:
+ try:
+ await msg.delete()
+ except (discord.NotFound, discord.Forbidden):
+ pass
+ except Exception as e:
+ await self.message.channel.send(
+ _("An error occurred while cleaning up messages. Error: {error}").format(
+ error=str(e)
+ )
+ )
+
+ async def get_random_target(self) -> Optional[discord.Member]:
+ """Get a random valid target from the guild."""
+ try:
+ # Get settings first - we need this for all checks
+ try:
+ settings = await self.cog.config.guild(self.interaction.guild).global_settings()
+ min_required = max(settings.get("min_steal_balance", 100), self.crime_data["min_reward"])
+ except AttributeError:
+ await self.interaction.channel.send(_("Error: Could not access guild settings. Please try again."))
+ return None
+ except Exception as e:
+ await self.interaction.channel.send(_("Error: Could not load settings. Error: {error}").format(error=str(e)))
+ return None
+
+ # Get last target ID once - cheap memory lookup
+ try:
+ last_target_id = await self.cog.config.member(self.interaction.user).last_target()
+ except Exception:
+ last_target_id = None
+
+ # Initial filtering with optimized memory usage
+ all_members = []
+ try:
+ for member in self.interaction.guild.members:
+ # Combined early filtering with clear conditions
+ if (member.bot or
+ member.id == self.interaction.user.id or
+ (last_target_id is not None and member.id == last_target_id)):
+ # Skip bots, self, and last target
+ continue
+ all_members.append(member)
+ except AttributeError:
+ await self.interaction.channel.send(_("Error: Could not access guild members. Please try again."))
+ return None
+
+ if not all_members:
+ await self.interaction.channel.send(_("No valid targets found! Everyone is either a bot or the only member found was already your last target."))
+ return None
+
+ random.shuffle(all_members)
+
+ # Get list of jailed members once instead of checking individually
+ jailed_members = set()
+ jail_check_errors = 0
+ for member in all_members:
+ try:
+ if await self.cog.is_jailed(member):
+ jailed_members.add(member.id)
+ except Exception:
+ jail_check_errors += 1
+ if jail_check_errors > min(5, len(all_members) * 0.1): # 10% or 5 errors, whichever is smaller
+ await self.interaction.channel.send(_("Error: Too many jail status check failures. Please try again."))
+ return None
+ continue
+
+ # Process members in chunks
+ chunk_size = min(25, max(10, len(all_members) // 20)) # Dynamic chunk size
+ total_checked = 0
+ balance_check_errors = 0
+ targeting_check_errors = 0
+
+ # Pre-cache bank data for first chunk to avoid initial lag
+ try:
+ first_chunk = all_members[:chunk_size]
+ balance_tasks = [bank.get_balance(member) for member in first_chunk if member.id not in jailed_members]
+ if balance_tasks:
+ await asyncio.gather(*balance_tasks, return_exceptions=True)
+ except Exception:
+ pass # Ignore pre-cache errors
+
+ while total_checked < len(all_members):
+ chunk_end = min(total_checked + chunk_size, len(all_members))
+ current_chunk = all_members[total_checked:chunk_end]
+
+ # Check balances in parallel for the chunk
+ balance_tasks = []
+ chunk_members = []
+ for member in current_chunk:
+ if member.id not in jailed_members:
+ balance_tasks.append(bank.get_balance(member))
+ chunk_members.append(member)
+
+ if balance_tasks:
+ try:
+ balance_results = await asyncio.gather(*balance_tasks, return_exceptions=True)
+ for member, balance_result in zip(chunk_members, balance_results):
+ if isinstance(balance_result, Exception):
+ if not isinstance(balance_result, discord.NotFound):
+ balance_check_errors += 1
+ continue
+
+ if balance_result >= min_required:
+ try:
+ can_target, reason = await can_target_for_crime(self.cog, self.interaction, member, self.crime_data, settings)
+ if can_target:
+ return member
+ except discord.NotFound:
+ continue
+ except Exception:
+ targeting_check_errors += 1
+
+ except Exception:
+ balance_check_errors += len(balance_tasks)
+
+ # Check error thresholds
+ if balance_check_errors > min(5, len(all_members) * 0.1):
+ await self.interaction.channel.send(_("Error: Multiple balance check failures. Please try again later."))
+ return None
+ if targeting_check_errors > min(5, len(all_members) * 0.1):
+ await self.interaction.channel.send(_("Error: Multiple targeting check failures. Please try again later."))
+ return None
+
+ total_checked += chunk_size
+
+ # Stop if we've checked enough members
+ if total_checked >= len(all_members) * 0.5:
+ break
+
+ return None
+
+ except discord.NotFound:
+ await self.interaction.channel.send(_("Error: The server or channel could not be found. Please try again."))
+ return None
+ except discord.Forbidden:
+ await self.interaction.channel.send(_("Error: I don't have permission to perform this action."))
+ return None
+ except Exception as e:
+ await self.interaction.channel.send(
+ _("An unexpected error occurred while finding a random target. Please try again later. Error: {error}")
+ .format(error=str(e))
+ )
+ return None
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ """Only allow the author that invoked the command to use the interaction"""
+ return interaction.user == self.interaction.user
+
+ async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item):
+ """Handle any errors that occur during button interactions"""
+ msg = await interaction.channel.send(
+ _("An error occurred. Please try again. Error: {error}").format(
+ error=str(error)
+ )
+ )
+ self.all_messages.append(msg)
+ await self.cleanup_messages()
+ self.stop()
+
+ @discord.ui.button(label="Random Target", style=discord.ButtonStyle.primary)
+ async def random_target(self, interaction: discord.Interaction, button: discord.ui.Button):
+ """Select a random target"""
+ if interaction.user.bot:
+ return
+
+ try:
+ await interaction.response.defer()
+
+ target = await self.get_random_target()
+ if target:
+ # Create crime view with selected target
+ crime_view = CrimeView(self.cog, interaction, self.crime_type, self.crime_data, target=target)
+
+ embed = discord.Embed(
+ title=f"{self.crime_data.get('emoji', '๐ฏ')} Target Selected",
+ description=f"Ready to attempt {self.crime_type.replace('_', ' ')} against {target.display_name}?",
+ color=discord.Color.red()
+ )
+ embed.add_field(
+ name="๐ Success Rate",
+ value=f"{int(self.crime_data['success_rate'] * 100)}%",
+ inline=True
+ )
+ embed.add_field(
+ name="๐ธ Potential Fine",
+ value=f"{int(self.crime_data['max_reward'] * self.crime_data['fine_multiplier']):,} {await bank.get_currency_name(interaction.guild)}",
+ inline=True
+ )
+
+ message = await interaction.channel.send(
+ embed=embed,
+ view=crime_view
+ )
+ crime_view.message = message
+ crime_view.all_messages = self.all_messages + [message] # Pass message list to crime view
+ self.stop()
+ else:
+ settings = await self.cog.config.guild(interaction.guild).global_settings()
+ no_target_msg = await interaction.channel.send(
+ _("No valid targets found. A valid target must:\n"
+ "โข Have at least {min_balance:,} {currency}\n"
+ "โข Not be your last target\n"
+ "โข Not be in jail\n"
+ "Try again later or choose a specific target.").format(
+ min_balance=settings.get("min_steal_balance", 100),
+ currency=await bank.get_currency_name(interaction.guild)
+ )
+ )
+ self.all_messages.append(no_target_msg)
+ except Exception as e:
+ error_msg = await interaction.channel.send(
+ _("An error occurred while selecting a target. Please try again. Error: {error}").format(
+ error=str(e)
+ )
+ )
+ self.all_messages.append(error_msg)
+
+ @discord.ui.button(label="Select Target", style=discord.ButtonStyle.success)
+ async def select_target(self, interaction: discord.Interaction, button: discord.ui.Button):
+ """Open modal to select specific target"""
+ if interaction.user.bot:
+ return
+
+ modal = TargetModal(self)
+ await interaction.response.send_modal(modal)
+
+ @discord.ui.button(label="Cancel", style=discord.ButtonStyle.danger)
+ async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
+ """Cancel target selection"""
+ if interaction.user.bot:
+ return
+
+ await interaction.response.defer()
+ msg = await interaction.channel.send(_("Crime cancelled."))
+ self.all_messages.append(msg)
+ await self.cleanup_messages()
+ self.target = None
+ self.stop()
+
+ async def on_timeout(self):
+ """Handle view timeout"""
+ try:
+ # Disable all buttons
+ for item in self.children:
+ item.disabled = True
+
+ # Try to update the message if it still exists
+ if self.message:
+ try:
+ await self.message.edit(view=self)
+ try:
+ msg = await self.message.channel.send(_("Target selection timed out."))
+ self.all_messages.append(msg)
+ await self.cleanup_messages()
+ except discord.HTTPException:
+ pass
+ except (discord.NotFound, discord.HTTPException):
+ pass
+ except Exception:
+ # Silently handle any other errors since the channel might not be available
+ pass
+ finally:
+ self.stop()
+
+async def can_target_for_crime(cog, interaction: discord.Interaction, target: discord.Member, crime_data: dict, settings: dict) -> Tuple[bool, str]:
+ """Check if a user can be targeted for a crime.
+
+ Args:
+ cog: The crime cog instance
+ interaction: The discord interaction
+ target: The member to check if can be targeted
+ crime_data: The crime data containing requirements
+ settings: Global settings containing minimum balance requirements
+
+ Returns:
+ tuple of (can_target, reason)
+ """
+ # First do basic checks using the utility function
+ # Add min_balance_required flag for crime actions
+ crime_data = {**crime_data, "min_balance_required": True}
+ can_target, reason = await can_target_user(interaction, target, crime_data, settings)
+ if not can_target:
+ return False, reason
+
+ # Check if target is jailed
+ if await cog.is_jailed(target):
+ return False, _("That user is in jail!")
+
+ # Check if target was last victim
+ last_target = await cog.config.member(interaction.user).last_target()
+ if last_target is not None and last_target == target.id:
+ return False, _("You can't target your last victim!")
+
+ return True, ""
+
+class MainMenuSelect(discord.ui.Select):
+ """Dropdown select menu for the main crime menu."""
+
+ def __init__(self, cog, ctx):
+ self.cog = cog
+ self.ctx = ctx
+
+ # Create base options list
+ self.base_options = [
+ discord.SelectOption(
+ label="Commit Crime",
+ value="crime",
+ description="Choose a crime to commit",
+ emoji="๐ฆน"
+ ),
+ discord.SelectOption(
+ label="Pay Bail",
+ value="bail",
+ description="Pay to get out of jail early",
+ emoji="๐ฐ"
+ ),
+ discord.SelectOption(
+ label="Attempt Jailbreak",
+ value="jailbreak",
+ description="Try to escape from jail",
+ emoji="๐"
+ ),
+ discord.SelectOption(
+ label="Leaderboard",
+ value="leaderboard",
+ description="View the crime leaderboard",
+ emoji="๐"
+ ),
+ discord.SelectOption(
+ label="View Status",
+ value="status",
+ description="Check your criminal status",
+ emoji="โณ"
+ ),
+ discord.SelectOption(
+ label="View Stats",
+ value="stats",
+ description="View your crime statistics",
+ emoji="๐"
+ ),
+ discord.SelectOption(
+ label="Inventory",
+ value="inventory",
+ description="View and manage your items",
+ emoji="๐"
+ ),
+ discord.SelectOption(
+ label="Black Market",
+ value="blackmarket",
+ description="Purchase special items and perks",
+ emoji="๐ดโโ ๏ธ"
+ )
+ ]
+
+ super().__init__(
+ placeholder="Choose an action...",
+ min_values=1,
+ max_values=1,
+ options=self.base_options
+ )
+
+ async def update_options(self):
+ """Update options based on user's current status."""
+ is_jailed = await self.cog.is_jailed(self.ctx.author)
+ member_data = await self.cog.config.member(self.ctx.author).all()
+
+ # Create a new options list based on user status
+ options = []
+ for option in self.base_options:
+ if option.value == "crime" and is_jailed:
+ # Update description for jailed users
+ new_option = discord.SelectOption(
+ label=option.label,
+ value=option.value,
+ description="(Unavailable) Cannot commit crimes while in jail",
+ emoji=option.emoji
+ )
+ elif option.value in ["bail", "jailbreak"] and not is_jailed:
+ # Update description for non-jailed users
+ new_option = discord.SelectOption(
+ label=option.label,
+ value=option.value,
+ description="(Unavailable) Only available while in jail",
+ emoji=option.emoji
+ )
+ elif option.value == "jailbreak" and member_data.get("attempted_jailbreak", False):
+ # Update description for failed jailbreak
+ new_option = discord.SelectOption(
+ label=option.label,
+ value=option.value,
+ description="(Unavailable) Already attempted jailbreak this sentence",
+ emoji=option.emoji
+ )
+ else:
+ new_option = option
+
+ options.append(new_option)
+
+ self.options = options
+
+ # Disable the entire select menu if all options would be disabled
+ self.disabled = is_jailed and all(opt.value in ["crime"] for opt in options) or \
+ (not is_jailed and all(opt.value in ["bail", "jailbreak"] for opt in options))
+
+ # Update the message with new options
+ if self.view and self.view.message:
+ await self.view.message.edit(view=self.view)
+
+ async def callback(self, interaction: discord.Interaction):
+ """Handle menu selection."""
+ if interaction.user.id != self.ctx.author.id:
+ await interaction.response.send_message("This menu is not for you!", ephemeral=True)
+ return
+
+ # Get current jail status
+ is_jailed = await self.cog.is_jailed(self.ctx.author)
+ member_data = await self.cog.config.member(self.ctx.author).all()
+ action = self.values[0]
+
+ # Validate the selection based on current status
+ if action == "crime" and is_jailed:
+ await interaction.response.send_message("You cannot commit crimes while in jail!", ephemeral=True)
+ return
+ elif action in ["bail", "jailbreak"] and not is_jailed:
+ await interaction.response.send_message("You are not in jail!", ephemeral=True)
+ return
+ elif action == "jailbreak" and member_data.get("attempted_jailbreak", False):
+ await interaction.response.send_message("You've already attempted to break out this sentence!", ephemeral=True)
+ return
+
+ # Delete the main menu message
+ try:
+ await self.view.message.delete()
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+ # Handle different actions
+ if action == "crime":
+ await self.ctx.invoke(self.cog.crime_commit)
+ elif action == "bail":
+ await self.ctx.invoke(self.cog.crime_bail)
+ elif action == "jailbreak":
+ await self.ctx.invoke(self.cog.crime_jailbreak)
+ elif action == "leaderboard":
+ await self.ctx.invoke(self.cog.crime_leaderboard)
+ elif action == "status":
+ await self.ctx.invoke(self.cog.crime_status)
+ elif action == "stats":
+ await self.ctx.invoke(self.cog.crime_stats)
+ elif action == "inventory":
+ await self.ctx.invoke(self.cog.city_inventory)
+ elif action == "blackmarket":
+ await self.ctx.invoke(self.cog.crime_blackmarket)
+
+class MainMenuView(discord.ui.View):
+ """View for the main crime menu."""
+
+ def __init__(self, cog, ctx):
+ super().__init__(timeout=60)
+ self.cog = cog
+ self.ctx = ctx
+ self.message: Optional[discord.Message] = None
+
+ # Add select menu
+ self.select_menu = MainMenuSelect(cog, ctx)
+ self.add_item(self.select_menu)
+
+ async def initialize_menu(self):
+ """Initialize the menu when first shown."""
+ await self.select_menu.update_options()
+
+ async def on_timeout(self):
+ """Handle view timeout."""
+ try:
+ self.select_menu.disabled = True
+ if self.message:
+ await self.message.edit(view=self)
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+class AddScenarioModal(discord.ui.Modal):
+ """Modal for adding a custom random scenario."""
+ def __init__(self, cog):
+ super().__init__(title="Add Custom Random Scenario")
+ self.cog = cog
+
+ self.name = discord.ui.TextInput(
+ label="Scenario Name",
+ placeholder="e.g. cookie_heist",
+ required=True,
+ min_length=3,
+ max_length=50
+ )
+ self.add_item(self.name)
+
+ self.risk = discord.ui.TextInput(
+ label="Risk Level",
+ placeholder="low, medium, or high",
+ required=True,
+ min_length=3,
+ max_length=6
+ )
+ self.add_item(self.risk)
+
+ self.attempt_text = discord.ui.TextInput(
+ label="Attempt Text",
+ placeholder="๐ช {user} sneaks into the cookie factory...",
+ required=True,
+ min_length=10,
+ max_length=200
+ )
+ self.add_item(self.attempt_text)
+
+ self.success_text = discord.ui.TextInput(
+ label="Success Text",
+ placeholder="๐ช {user} made off with cookies worth {amount} {currency}!",
+ required=True,
+ min_length=10,
+ max_length=200
+ )
+ self.add_item(self.success_text)
+
+ self.fail_text = discord.ui.TextInput(
+ label="Fail Text",
+ placeholder="๐ช {user} got caught with their hand in the cookie jar!",
+ required=True,
+ min_length=10,
+ max_length=200
+ )
+ self.add_item(self.fail_text)
+
+ async def on_submit(self, interaction: discord.Interaction):
+ """Handle form submission."""
+ # Validate risk level
+ risk = self.risk.value.lower()
+ if risk not in ["low", "medium", "high"]:
+ await interaction.response.send_message(
+ "Invalid risk level. Must be 'low', 'medium', or 'high'.",
+ ephemeral=True
+ )
+ return
+
+ # Get corresponding success rate and other values based on risk
+ if risk == "low":
+ success_rate = SUCCESS_RATE_HIGH
+ min_reward = 100
+ max_reward = 300
+ jail_time = 180
+ fine_multiplier = 0.3
+ elif risk == "medium":
+ success_rate = SUCCESS_RATE_MEDIUM
+ min_reward = 300
+ max_reward = 800
+ jail_time = 300
+ fine_multiplier = 0.4
+ else: # high
+ success_rate = SUCCESS_RATE_LOW
+ min_reward = 800
+ max_reward = 2000
+ jail_time = 600
+ fine_multiplier = 0.5
+
+ # Create new scenario
+ new_scenario = {
+ "name": self.name.value.lower(),
+ "risk": risk,
+ "min_reward": min_reward,
+ "max_reward": max_reward,
+ "success_rate": success_rate,
+ "jail_time": jail_time,
+ "fine_multiplier": fine_multiplier,
+ "attempt_text": self.attempt_text.value,
+ "success_text": self.success_text.value,
+ "fail_text": self.fail_text.value
+ }
+
+ # Add to guild's custom scenarios
+ await add_custom_scenario(self.cog.config, interaction.guild, new_scenario)
+
+ # Send confirmation
+ embed = discord.Embed(
+ title="โ
Custom Scenario Added!",
+ description=f"Your scenario '{self.name.value}' has been added to this server's random crime pool.",
+ color=discord.Color.green()
+ )
+ embed.add_field(name="Risk Level", value=risk.title(), inline=True)
+ embed.add_field(name="Success Rate", value=f"{int(success_rate * 100)}%", inline=True)
+ embed.add_field(name="Reward Range", value=f"{min_reward:,} - {max_reward:,}", inline=True)
+
+ await interaction.response.send_message(embed=embed)
+
+class JailOptionsView(discord.ui.View):
+ """View for jail options after a failed crime."""
+ def __init__(self, cog, interaction: discord.Interaction, jail_time: int):
+ super().__init__(timeout=60)
+ self.cog = cog
+ self.interaction = interaction
+ self.jail_time = jail_time
+ self.message = None
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ """Only allow the original user to use the view."""
+ return interaction.user.id == self.interaction.user.id
+
+ async def on_timeout(self) -> None:
+ """Handle view timeout"""
+ try:
+ for item in self.children:
+ item.disabled = True
+ await self.message.edit(view=self)
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+ @discord.ui.button(label="Jail Break", style=discord.ButtonStyle.danger, emoji="๐")
+ async def jailbreak(self, interaction: discord.Interaction, button: discord.ui.Button):
+ """Attempt a jailbreak"""
+ if interaction.user.bot:
+ return
+
+ try:
+ await interaction.response.defer()
+
+ # Disable the jailbreak button immediately after deferring
+ button.disabled = True
+ await self.message.edit(view=self)
+
+ # Create context from interaction
+ ctx = await self.cog.bot.get_context(interaction.message)
+ ctx.author = interaction.user
+
+ # Use existing jailbreak command
+ await self.cog.crime_jailbreak(ctx)
+
+ except Exception as e:
+ await interaction.followup.send(
+ _("An error occurred while attempting jailbreak. Error: {error}").format(
+ error=str(e)
+ ),
+ ephemeral=True
+ )
+
+ @discord.ui.button(label="Pay Bail", style=discord.ButtonStyle.success, emoji="๐ธ")
+ async def pay_bail(self, interaction: discord.Interaction, button: discord.ui.Button):
+ """Pay bail to get out of jail"""
+ if interaction.user.bot:
+ return
+
+ try:
+ await interaction.response.defer()
+
+ # Create context from interaction
+ ctx = await self.cog.bot.get_context(interaction.message)
+ ctx.author = interaction.user
+
+ # Use existing bail command
+ await self.cog.crime_bail(ctx)
+
+ # Disable buttons after use
+ for item in self.children:
+ item.disabled = True
+ await self.message.edit(view=self)
+ self.stop()
+
+ except Exception as e:
+ await interaction.followup.send(
+ _("An error occurred while paying bail. Error: {error}").format(
+ error=str(e)
+ ),
+ ephemeral=True
+ )
+
diff --git a/city/info.json b/city/info.json
new file mode 100644
index 0000000..736b5ca
--- /dev/null
+++ b/city/info.json
@@ -0,0 +1,16 @@
+{
+ "name": "City",
+ "short": "A comprehensive city simulation system with various activities",
+ "description": "A modular city simulation system that includes various activities for users to engage in. Currently features a crime system for earning credits through virtual crimes with different risk levels, cooldowns, rewards, jail system, jail breaks, dynamic events, and more.",
+ "end_user_data_statement": "This cog stores user data including:\n- Cooldown timers for various activities\n- Statistics for activities (e.g., successful/failed crimes)\n- Jail status and duration\n- Last target information for anti-farming\nAll data can be removed with the standard Red delete data commands.",
+ "install_msg": "Thanks for installing the City cog!\n\nNotes:\n- Uses Config to store user stats and settings\n- Moderate memory usage for tracking active crimes and jail status\n- Requires bank to be registered (`[p]bank register`)\n- Requires manage_messages permission for button interactions\n- Anti-farming measures track last target IDs\n\nGet started with `[p]crime` to explore the crime system. Use `[p]crimeset` for configuration.",
+ "author": ["CalaMariGold"],
+ "required_cogs": {},
+ "requirements": [],
+ "tags": ["fun", "economy", "games", "crime", "city", "simulation", "interactive"],
+ "min_bot_version": "3.5.0",
+ "min_python_version": [3, 8, 1],
+ "hidden": false,
+ "disabled": false,
+ "type": "COG"
+}
diff --git a/city/inventory.py b/city/inventory.py
new file mode 100644
index 0000000..c14ff96
--- /dev/null
+++ b/city/inventory.py
@@ -0,0 +1,567 @@
+"""Inventory system for the City cog.
+
+This module provides a flexible inventory system that can be used by any module
+in the City cog. It supports both consumable and permanent items, with features
+for activating and selling items.
+
+The system is designed to be extensible, allowing different modules to register
+their own items with custom activation and sale behaviors.
+
+"""
+
+from typing import Dict, List, Optional, Union, Any, Tuple, Callable
+import discord
+from redbot.core import bank, commands
+import time
+from datetime import datetime, timedelta
+import asyncio
+
+async def cleanup_inventory(member_data: Dict[str, Any], item_registry: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
+ """Clean up expired or invalid items from member data.
+
+ Args:
+ member_data (Dict[str, Any]): The member's data containing inventory and perks
+ item_registry (Dict[str, Dict[str, Any]]): Registry of available items
+
+ Returns:
+ Dict[str, Any]: The cleaned member data
+ """
+ current_time = int(time.time())
+
+ # Clean up active items
+ if "active_items" in member_data:
+ active_items = member_data["active_items"]
+ to_remove = []
+
+ for item_id, status in active_items.items():
+ # Remove if item no longer exists in registry
+ if item_id not in item_registry:
+ to_remove.append(item_id)
+ continue
+
+ item = item_registry[item_id]
+ if "duration" in item:
+ # Remove if duration expired
+ end_time = status.get("end_time", 0)
+ if end_time <= current_time:
+ to_remove.append(item_id)
+ elif "uses" in status:
+ # Remove if no uses left
+ if status["uses"] <= 0:
+ to_remove.append(item_id)
+
+ for item_id in to_remove:
+ del active_items[item_id]
+
+ # Clean up purchased perks
+ if "purchased_perks" in member_data:
+ # Remove perks that no longer exist in registry
+ member_data["purchased_perks"] = [
+ perk_id for perk_id in member_data["purchased_perks"]
+ if perk_id in item_registry
+ ]
+
+ return member_data
+
+class InventoryView(discord.ui.View):
+ """A view for displaying and managing a user's inventory.
+
+ This view provides functionality for activating and selling items from the user's inventory.
+ It supports both consumable and permanent items, with appropriate handling for each type.
+
+ Attributes:
+ ctx (commands.Context): The command context
+ cog (commands.Cog): The cog instance that created this view
+ member_data (Dict[str, Any]): The member's data containing inventory and perks
+ item_registry (Dict[str, Dict[str, Any]]): Registry of available items
+ message (Optional[discord.Message]): The message containing this view
+ """
+
+ def __init__(self, ctx: commands.Context, cog: commands.Cog, member_data: Dict[str, Any], item_registry: Dict[str, Dict[str, Any]]) -> None:
+ super().__init__(timeout=180)
+ self.ctx = ctx
+ self.cog = cog
+ self.member_data = member_data
+ self.item_registry = item_registry
+ self.message: Optional[discord.Message] = None
+
+ async def _update_options(self) -> None:
+ """Update the select menu options based on the user's inventory."""
+ # Clear existing options
+ for child in self.children[:]:
+ self.remove_item(child)
+
+ current_time = int(time.time())
+ owned_items = []
+ currency_name = await bank.get_currency_name(self.ctx.guild)
+
+ # Add owned perks
+ for perk_id in self.member_data.get("purchased_perks", []):
+ if perk_id in self.item_registry:
+ owned_items.append((perk_id, self.item_registry[perk_id], None))
+
+ # Add active items
+ for item_id, status in self.member_data.get("active_items", {}).items():
+ if item_id not in self.item_registry:
+ continue
+
+ item = self.item_registry[item_id]
+ if "duration" in item:
+ end_time = status.get("end_time", 0)
+ if end_time > current_time:
+ remaining = end_time - current_time
+ owned_items.append((item_id, item, f"{remaining // 3600}h {(remaining % 3600) // 60}m remaining"))
+ elif "uses" in status:
+ uses = status.get("uses", 0)
+ if uses > 0:
+ owned_items.append((item_id, item, f"{uses} uses remaining"))
+
+ if not owned_items:
+ # Add placeholder if no items
+ self.add_item(discord.ui.Select(
+ placeholder="No items in inventory",
+ options=[discord.SelectOption(label="Empty", value="empty", description="Your inventory is empty")],
+ disabled=True
+ ))
+ return
+
+ # Add activation select
+ activate_options = []
+ for item_id, item, status in owned_items:
+ # Skip perks that aren't toggleable
+ if item["type"] == "perk" and not item.get("toggleable", False):
+ continue
+
+ description = item["description"]
+ if status:
+ description = f"{status} - {description}"
+
+ if item_id == "notify_ping":
+ current_status = self.member_data.get("notify_on_release", False)
+ label = f"{item['name']} ({'Enabled' if current_status else 'Disabled'})"
+ else:
+ label = item['name']
+
+ activate_options.append(
+ discord.SelectOption(
+ label=label,
+ value=f"activate_{item_id}",
+ description=description,
+ emoji=item.get("emoji", "๐ฆ")
+ )
+ )
+
+ if activate_options:
+ activate_select = discord.ui.Select(
+ placeholder="Select an item to use/toggle",
+ options=activate_options,
+ row=0
+ )
+ activate_select.callback = self._handle_activation
+ self.add_item(activate_select)
+
+ # Add sell select for sellable items
+ sell_options = []
+ for item_id, item, status in owned_items:
+ if not item.get("can_sell", True):
+ continue
+
+ sell_price = int(item["cost"] * (0.25 if item["type"] == "perk" else 0.5))
+
+ description = f"Sell for {sell_price:,} {currency_name}"
+ if status:
+ description = f"{status} - {description}"
+
+ sell_options.append(
+ discord.SelectOption(
+ label=f"Sell {item['name']}",
+ value=f"sell_{item_id}",
+ description=description,
+ emoji="๐ฐ"
+ )
+ )
+
+ if sell_options:
+ sell_select = discord.ui.Select(
+ placeholder="Select an item to sell",
+ options=sell_options,
+ row=1
+ )
+ sell_select.callback = self._handle_sale
+ self.add_item(sell_select)
+
+ async def _create_embed(self) -> discord.Embed:
+ """Create the inventory embed.
+
+ Returns:
+ discord.Embed: The created embed
+ """
+ embed = discord.Embed(
+ title="๐ Your Inventory",
+ description="Select an item to activate or sell it.",
+ color=discord.Color.blue()
+ )
+
+ # Add perks section
+ perks = []
+ for perk_id in self.member_data.get("purchased_perks", []):
+ if perk_id in self.item_registry:
+ perk = self.item_registry[perk_id]
+ status = ""
+ if perk_id == "notify_ping":
+ status = " (Enabled)" if self.member_data.get("notify_on_release", False) else " (Disabled)"
+ perks.append(f"{perk['emoji']} **{perk['name']}**{status}\nโณ {perk['description']}")
+
+ if perks:
+ embed.add_field(
+ name="__๐ Permanent Perks__",
+ value="\n".join(perks),
+ inline=False
+ )
+
+ # Add active items section
+ current_time = int(time.time())
+ active_items = []
+ for item_id, status in self.member_data.get("active_items", {}).items():
+ if item_id not in self.item_registry:
+ continue
+
+ item = self.item_registry[item_id]
+ if "duration" in item:
+ end_time = status.get("end_time", 0)
+ if end_time > current_time:
+ remaining = end_time - current_time
+ time_str = format_time_remaining(remaining)
+ active_items.append(f"{item['emoji']} **{item['name']}**\nโณ Time remaining: {time_str}")
+ else:
+ uses = status.get("uses", 0)
+ if uses > 0:
+ active_items.append(f"{item['emoji']} **{item['name']}**\nโณ {uses} uses remaining")
+
+ if active_items:
+ embed.add_field(
+ name="__๐ฆ Active Items__",
+ value="\n".join(active_items),
+ inline=False
+ )
+
+ if not perks and not active_items:
+ embed.description = "Your inventory is empty!"
+
+ return embed
+
+ async def _update_message(self) -> None:
+ """Update both the view and embed of the inventory message."""
+ # Create tasks for parallel execution
+ tasks = [
+ self._create_embed(),
+ self._update_options(),
+ bank.get_currency_name(self.ctx.guild) # Get this once for the whole update
+ ]
+
+ # Wait for all tasks to complete
+ embed, _, _ = await asyncio.gather(*tasks)
+
+ # Update message with new embed and view
+ await self.message.edit(embed=embed, view=self)
+
+ async def _handle_activation(self, interaction: discord.Interaction) -> None:
+ """Handle item activation.
+
+ Args:
+ interaction (discord.Interaction): The interaction that triggered this callback
+ """
+ if interaction.user.id != self.ctx.author.id:
+ await interaction.response.send_message("This menu is not for you!", ephemeral=True)
+ return
+
+ try:
+ item_id = interaction.data["values"][0].replace("activate_", "")
+ item = self.item_registry[item_id]
+ except (KeyError, AttributeError):
+ await interaction.response.send_message(
+ "โ This item no longer exists!",
+ ephemeral=True
+ )
+ return
+
+ # Handle activation based on item type
+ if item["type"] == "perk":
+ if not item.get("toggleable", False):
+ await interaction.response.send_message(
+ "โ This perk cannot be toggled!",
+ ephemeral=True
+ )
+ return
+
+ if item_id == "notify_ping":
+ # Toggle notification status
+ async with self.cog.config.member(self.ctx.author).all() as member_data:
+ current_status = member_data.get("notify_on_release", False)
+ member_data["notify_on_release"] = not current_status
+ new_status = member_data["notify_on_release"]
+ self.member_data = member_data
+
+ await interaction.response.send_message(
+ f"๐ Notifications are now {'enabled' if new_status else 'disabled'}",
+ ephemeral=True
+ )
+ await self._update_message()
+ else:
+ await interaction.response.send_message(
+ f"โจ Activated {item['emoji']} **{item['name']}**",
+ ephemeral=True
+ )
+ await self._update_message()
+ elif item["type"] == "consumable":
+ async with self.cog.config.member(self.ctx.author).all() as member_data:
+ # Clean up expired items first
+ member_data = await cleanup_inventory(member_data, self.item_registry)
+
+ if "active_items" not in member_data:
+ member_data["active_items"] = {}
+
+ # Handle jail pass
+ if item_id == "jail_pass":
+ # Check if user is in jail
+ if not await self.cog.is_jailed(self.ctx.author):
+ await interaction.response.send_message(
+ "โ You're not in jail! Save your jail pass for when you need it.",
+ ephemeral=True
+ )
+ return
+
+ # Use the jail pass
+ if member_data["active_items"][item_id]["uses"] <= 1:
+ del member_data["active_items"][item_id]
+ else:
+ member_data["active_items"][item_id]["uses"] -= 1
+
+ # Release from jail
+ member_data["jail_until"] = 0
+ member_data["jail_channel"] = None # Clear jail channel
+ member_data["attempted_jailbreak"] = False # Reset jailbreak attempt
+
+ await interaction.response.send_message(
+ "๐ Used your Get Out of Jail Free card! You are now free.",
+ ephemeral=True
+ )
+
+ self.member_data = member_data
+ await self._update_message()
+ return
+
+ # Handle duration-based items
+ if "duration" in item:
+ current_time = int(time.time())
+
+ # Check if item is already active
+ if item_id in member_data["active_items"]:
+ existing_end = member_data["active_items"][item_id].get("end_time", current_time)
+ if existing_end > current_time:
+ remaining = existing_end - current_time
+ time_str = format_time_remaining(remaining)
+ await interaction.response.send_message(
+ f"โ This item is already active for {time_str}!",
+ ephemeral=True
+ )
+ return
+
+ # Activate item with new duration
+ member_data["active_items"][item_id] = {"end_time": current_time + item["duration"]}
+ time_str = format_time_remaining(item["duration"])
+
+ await interaction.response.send_message(
+ f"โจ Activated {item['emoji']} **{item['name']}** for {time_str}",
+ ephemeral=True
+ )
+ # Handle use-based items
+ else:
+ current_uses = member_data["active_items"].get(item_id, {}).get("uses", 0)
+ if current_uses <= 0: # Item not active or no uses left
+ if "uses" in item: # New item with default uses
+ member_data["active_items"][item_id] = {"uses": item["uses"]}
+ await interaction.response.send_message(
+ f"โจ Added {item['emoji']} **{item['name']}** with {item['uses']} uses",
+ ephemeral=True
+ )
+ self.member_data = member_data
+ await self._update_message()
+ return
+ else:
+ await interaction.response.send_message(
+ "โ This item has no uses remaining!",
+ ephemeral=True
+ )
+ return
+
+ # Use the item
+ if current_uses <= 1:
+ del member_data["active_items"][item_id]
+ else:
+ member_data["active_items"][item_id]["uses"] = current_uses - 1
+
+ await interaction.response.send_message(
+ f"โจ Used {item['emoji']} **{item['name']}**" +
+ (f" ({current_uses-1} uses remaining)" if current_uses > 1 else ""),
+ ephemeral=True
+ )
+
+ self.member_data = member_data
+ await self._update_message()
+
+ async def _handle_sale(self, interaction: discord.Interaction) -> None:
+ """Handle item sale.
+
+ Args:
+ interaction (discord.Interaction): The interaction that triggered this callback
+ """
+ if interaction.user.id != self.ctx.author.id:
+ await interaction.response.send_message("This menu is not for you!", ephemeral=True)
+ return
+
+ try:
+ item_id = interaction.data["values"][0].replace("sell_", "")
+ item = self.item_registry[item_id]
+ except (KeyError, AttributeError):
+ await interaction.response.send_message(
+ "โ This item no longer exists!",
+ ephemeral=True
+ )
+ return
+
+ # Calculate sell price (25% for perks, 50% for consumables)
+ sell_price = int(item["cost"] * (0.25 if item["type"] == "perk" else 0.5))
+ currency_name = await bank.get_currency_name(self.ctx.guild)
+
+ # Remove item and give credits
+ async with self.cog.config.member(self.ctx.author).all() as member_data:
+ # Clean up expired items first
+ member_data = await cleanup_inventory(member_data, self.item_registry)
+
+ if item["type"] == "perk":
+ if item_id not in member_data.get("purchased_perks", []):
+ await interaction.response.send_message(
+ "โ You no longer have this perk!",
+ ephemeral=True
+ )
+ return
+
+ member_data["purchased_perks"].remove(item_id)
+
+ # Special handling for notify_ping
+ if item_id == "notify_ping":
+ member_data["notify_on_release"] = False
+ member_data["notify_unlocked"] = False
+ else:
+ if item_id not in member_data.get("active_items", {}):
+ await interaction.response.send_message(
+ "โ You no longer have this item!",
+ ephemeral=True
+ )
+ return
+
+ del member_data["active_items"][item_id]
+
+ await bank.deposit_credits(self.ctx.author, sell_price)
+ self.member_data = member_data
+
+ await interaction.response.send_message(
+ f"๐ฐ Sold {item['emoji']} **{item['name']}** for {sell_price:,} {currency_name}",
+ ephemeral=True
+ )
+ await self._update_message()
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ """Check if the interaction is valid.
+
+ Args:
+ interaction (discord.Interaction): The interaction to check
+
+ Returns:
+ bool: True if the interaction is valid, False otherwise
+ """
+ return interaction.user.id == self.ctx.author.id
+
+ async def on_timeout(self) -> None:
+ """Handle view timeout by disabling all components."""
+ try:
+ for child in self.children:
+ child.disabled = True
+ await self.message.edit(view=self)
+ except (discord.NotFound, discord.HTTPException):
+ pass
+
+async def display_inventory(cog: commands.Cog, ctx: commands.Context, item_registry: Dict[str, Dict[str, Any]]) -> None:
+ """Display a user's inventory.
+
+ This function creates and displays an interactive view that allows users to
+ manage their inventory items. Users can activate or sell items through the view.
+
+ Args:
+ cog (commands.Cog): The cog instance
+ ctx (commands.Context): The command context
+ item_registry (Dict[str, Dict[str, Any]]): Registry of available items
+
+ Note:
+ The item_registry should contain items in the following format:
+ {
+ "item_id": {
+ "name": str,
+ "description": str,
+ "cost": int,
+ "type": "consumable" | "perk",
+ "emoji": str,
+ "can_sell": bool = True,
+ "duration": int = None, # Optional: duration in seconds
+ "uses": int = None # Optional: number of uses
+ }
+ }
+ """
+ # Get and clean member data
+ member_data = await cog.config.member(ctx.author).all()
+ member_data = await cleanup_inventory(member_data, item_registry)
+
+ # Save cleaned data
+ await cog.config.member(ctx.author).set(member_data)
+
+ # Create view and initialize
+ view = InventoryView(ctx, cog, member_data, item_registry)
+
+ # Create tasks for parallel execution
+ tasks = [
+ view._create_embed(),
+ view._update_options()
+ ]
+
+ # Wait for all tasks to complete
+ embed, _ = await asyncio.gather(*tasks)
+
+ # Send message with embed and view
+ view.message = await ctx.send(embed=embed, view=view)
+
+def format_time_remaining(seconds: int) -> str:
+ """Format remaining time into a human-readable string.
+
+ Args:
+ seconds (int): Number of seconds remaining
+
+ Returns:
+ str: Formatted time string (e.g. "2d 5h 30m" or "45m")
+ """
+ if seconds <= 0:
+ return "0m"
+
+ days = seconds // 86400
+ hours = (seconds % 86400) // 3600
+ minutes = (seconds % 3600) // 60
+
+ parts = []
+ if days > 0:
+ parts.append(f"{days}d")
+ if hours > 0 or (days > 0 and minutes > 0): # Show hours if we have days and minutes
+ parts.append(f"{hours}h")
+ if minutes > 0 or not parts: # Always show minutes if no larger unit
+ parts.append(f"{minutes}m")
+
+ return " ".join(parts)
\ No newline at end of file
diff --git a/city/plan.md b/city/plan.md
new file mode 100644
index 0000000..c6a4166
--- /dev/null
+++ b/city/plan.md
@@ -0,0 +1,156 @@
+# City Cog Development Plan
+
+## Current Modules
+
+### 1. Crime Module
+#### Jail System Refactoring Plan
+1. **Create Dedicated Jail Module** (`city/crime/jail.py`) โ
+ - Core JailManager class โ
+ - Centralized state management โ
+ - Standardized error handling โ
+ - Event dispatching system โ
+
+2. **JailManager Class Structure**
+ ```python
+ class JailManager:
+ # Core Jail Functions โ
+ - get_jail_time_remaining(member)
+ - send_to_jail(member, time, channel)
+ - release_from_jail(member)
+ - is_in_jail(member)
+
+ # Bail System โ
+ - calculate_bail_cost(remaining_time)
+ - process_bail_payment(member, amount)
+ - can_pay_bail(member)
+ - format_bail_embed(member)
+
+ # Jailbreak System
+ - get_jailbreak_scenario()
+ - process_jailbreak_attempt(member)
+ - apply_jailbreak_event(member, event)
+
+ # Perk Management โ
+ - apply_sentence_reduction(time)
+ - has_jail_reducer(member)
+
+ # Notification System โ
+ - schedule_release_notification(member)
+ - cancel_notification(member)
+ - send_notification(member, channel)
+
+ # State Management โ
+ - get_jail_state(member)
+ - update_jail_state(member, state)
+ - clear_jail_state(member)
+ ```
+
+3. **Implementation Phases**
+
+ a) Core Infrastructure โ
+ - [x] Create JailManager class
+ - [x] Implement state management
+ - [x] Set up error handling system
+ - [x] Add logging and debugging
+
+ b) Feature Migration
+ - [x] Move jail time calculations
+ - [x] Migrate bail system
+ - [x] Transfer jailbreak mechanics
+ - [x] Port notification system
+
+ c) Integration Updates
+ - [ ] Update command handlers
+ - [ ] Modify view classes
+ - [ ] Adjust crime scenarios
+ - [ ] Update black market integration
+
+4. **Data Structure Updates** โ
+ ```python
+ JailState = {
+ "jail_until": int,
+ "attempted_jailbreak": bool,
+ "jail_channel": Optional[int],
+ "notify_on_release": bool,
+ "reduced_sentence": bool,
+ "original_sentence": int
+ }
+ ```
+
+5. **Error Handling** โ
+ - Custom exception classes
+ - Standardized error messages
+ - Proper error propagation
+ - Recovery mechanisms
+
+6. **Migration Strategy**
+
+ a) Phase 1: Parallel Implementation
+ - [x] Implement new system alongside existing
+ - [ ] Validate all functionality
+
+ b) Phase 2: Cleanup
+ - [ ] Remove old implementation
+ - [ ] Clean up imports
+ - [ ] Update documentation
+
+7. **Validation Checklist**
+ - [ ] All existing features work
+ - [ ] UI remains unchanged
+ - [ ] Perks function correctly
+ - [ ] Notifications work reliably
+ - [ ] No performance regression
+ - [ ] State consistency maintained
+ - [ ] Error handling improved
+
+#### Next Steps (Priority Order)
+- [ ] Implement jailbreak refactor
+- [ ] Update command handlers and views
+- [ ] New Features
+ - [ ] "The Purge" blackmarket item implementation
+ - [ ] Additional balancing and tweaks
+ - [ ] More custom scenarios
+
+### 2. Business Module (In Development)
+#### Core Features
+- [ ] Business creation and management
+- [ ] Multiple industry types
+ - [ ] Trading
+ - [ ] Manufacturing
+ - [ ] Retail
+- [ ] Vault system
+- [ ] Passive income generation
+- [ ] Business robbery mechanics
+- [ ] Security systems
+
+#### Business UI
+- [ ] Business management interface
+- [ ] Shop interface
+- [ ] Status displays
+- [ ] Transaction history
+
+#### Business Progression
+- [ ] Level system
+- [ ] Upgrades shop
+- [ ] Industry-specific perks
+- [ ] Achievement system
+
+## Planned Modules
+
+### TBA
+
+## Technical Improvements
+
+### Performance
+- [ ] Optimize database queries
+- [ ] Cache frequently accessed data
+- [ ] Memory usage optimization
+
+### Code Quality
+- [ ] Documentation improvements
+- [ ] Code refactoring
+- [ ] Type hints implementation
+
+### UI/UX
+- [ ] Consistent UI
+- [ ] Consistent error messages
\ No newline at end of file
diff --git a/city/utils.py b/city/utils.py
new file mode 100644
index 0000000..6c41868
--- /dev/null
+++ b/city/utils.py
@@ -0,0 +1,293 @@
+"""Utility functions for the city cog."""
+
+import discord
+from redbot.core import bank, commands
+from datetime import datetime, timezone
+import time
+import random
+from typing import Optional, Tuple, Union, List, Dict
+
+def format_cooldown_time(seconds: int, include_emoji: bool = True) -> str:
+ """Format cooldown time into a human readable string.
+
+ Args:
+ seconds: Number of seconds to format
+ include_emoji: Whether to include the โณ emoji
+
+ Returns:
+ Formatted string like "2h 30m" or "5m 30s"
+ """
+ if seconds <= 0:
+ return "โ
" if include_emoji else "Ready"
+
+ hours = seconds // 3600
+ minutes = (seconds % 3600) // 60
+ seconds = seconds % 60
+
+ if hours > 0:
+ time_str = f"{hours}h {minutes}m"
+ elif minutes > 0:
+ time_str = f"{minutes}m {seconds}s"
+ else:
+ time_str = f"{seconds}s"
+
+ return f"โณ {time_str}" if include_emoji else time_str
+
+async def can_target_user(interaction_or_ctx: Union[discord.Interaction, commands.Context], target: discord.Member, action_data: dict, settings: dict) -> Tuple[bool, str]:
+ """Base targeting checks for any action.
+
+ Args:
+ interaction_or_ctx: Either a discord.Interaction or commands.Context
+ target: The member to check if can be targeted
+ action_data: The action data containing requirements
+ settings: Global settings containing minimum balance requirements
+
+ Returns:
+ tuple of (can_target, reason)
+ """
+ # Get the author from either interaction or context
+ if isinstance(interaction_or_ctx, discord.Interaction):
+ author = interaction_or_ctx.user
+ else:
+ author = interaction_or_ctx.author
+
+ # Can't target self
+ if target.id == author.id:
+ return False, "You can't target yourself!"
+
+ # Can't target bots
+ if target.bot:
+ return False, "You can't target bots!"
+
+ try:
+ # Check target's balance if minimum is specified
+ if "min_balance_required" in action_data:
+ target_balance = await bank.get_balance(target)
+ min_balance = settings.get("min_steal_balance", 100)
+
+ if target_balance < min_balance:
+ return False, f"Target must have at least {min_balance:,} credits!"
+
+ # For actions with steal percentages, check if target has enough based on percentage
+ if "steal_percentage" in action_data:
+ steal_percentage = action_data["steal_percentage"]
+ max_steal = settings.get("max_steal_amount", 1000)
+
+ # Calculate potential steal amount
+ potential_steal = min(int(target_balance * steal_percentage), max_steal)
+
+ if potential_steal < action_data.get("min_reward", 0):
+ return False, f"Target doesn't have enough credits! (Needs at least {int(action_data['min_reward'] / steal_percentage):,} credits)"
+
+ except Exception as e:
+ return False, f"Error checking target's balance: {str(e)}"
+
+ return True, ""
+
+async def calculate_stolen_amount(target: discord.Member, crime_data: dict, settings: dict) -> int:
+ """Calculate how much to steal from the target based on settings.
+
+ Args:
+ target: The member being stolen from
+ crime_data: The crime data containing requirements
+ settings: Global settings containing maximum steal amount
+
+ Returns:
+ Amount to steal, or 0 if error
+ """
+ try:
+ # Get target's balance
+ target_balance = await bank.get_balance(target)
+
+ # For crimes with steal percentages, calculate based on balance
+ if "min_steal_percentage" in crime_data and "max_steal_percentage" in crime_data:
+ # Get random percentage between min and max
+ steal_percentage = random.uniform(
+ crime_data["min_steal_percentage"],
+ crime_data["max_steal_percentage"]
+ )
+
+ # Calculate amount to steal
+ amount = int(target_balance * steal_percentage)
+
+ # Cap at max_steal_amount from settings
+ max_steal = settings.get("max_steal_amount", 1000)
+ amount = min(amount, max_steal)
+
+ # Cap at crime's max_reward
+ amount = min(amount, crime_data["max_reward"])
+
+ # Ensure at least min_reward if target has enough balance
+ if amount < crime_data["min_reward"] and target_balance >= (crime_data["min_reward"] / crime_data["min_steal_percentage"]):
+ amount = crime_data["min_reward"]
+
+ return amount
+
+ # For other crimes, use normal random range
+ return random.randint(crime_data["min_reward"], crime_data["max_reward"])
+
+ except Exception:
+ return 0
+
+def get_crime_emoji(crime_type: str) -> str:
+ """Get the appropriate emoji for a crime type.
+
+ Args:
+ crime_type: The type of crime (pickpocket, mugging, rob_store, bank_heist, random)
+
+ Returns:
+ The corresponding emoji for the crime type:
+ โข ๐งค for pickpocket
+ โข ๐ช for mugging
+ โข ๐ช for store robbery
+ โข ๐ for bank heist
+ โข ๐ฒ for random crimes
+ """
+ emojis = {
+ "pickpocket": "๐งค",
+ "mugging": "๐ช",
+ "rob_store": "๐ช",
+ "bank_heist": "๐",
+ "random": "๐ฒ"
+ }
+ return emojis.get(crime_type, "๐ฆน") # Default to generic criminal emoji
+
+def get_risk_emoji(risk_level: str) -> str:
+ """Get the emoji for a risk level.
+
+ Args:
+ risk_level: The risk level (low, medium, high)
+
+ Returns:
+ Emoji representing the risk level
+ """
+ emoji_map = {
+ "low": "๐ข",
+ "medium": "๐ก",
+ "high": "๐ด"
+ }
+ return emoji_map.get(risk_level, "๐ค")
+
+def format_crime_description(crime_type: str, data: dict, cooldown_status: str) -> str:
+ """Format a crime's description for display in embeds.
+
+ Args:
+ crime_type: The type of crime
+ data: Dictionary containing crime data (success_rate, min_reward, max_reward, etc.)
+ cooldown_status: Formatted cooldown status string
+
+ Returns:
+ A formatted description string containing:
+ โข Success rate percentage
+ โข Reward range (percentage based for pickpocket/mugging)
+ โข Risk level indicator
+ โข Cooldown status
+ โข Whether it requires a target
+ """
+ # Get risk emoji
+ risk_emoji = "???" if crime_type == "random" else "๐ข" if data["risk"] == "low" else "๐ก" if data["risk"] == "medium" else "๐ด"
+
+ # Format description based on crime type
+ if crime_type == "random":
+ description = [
+ "**Success Rate:** ???",
+ "**Reward:** ???",
+ f"**Risk Level:** {risk_emoji}",
+ f"**Cooldown:** {cooldown_status}"
+ ]
+ elif crime_type == "pickpocket":
+ description = [
+ f"**Success Rate:** {int(data['success_rate'] * 100)}%",
+ "**Reward:** 1-10% of target's balance (max 500)",
+ f"**Risk Level:** {risk_emoji}",
+ f"**Cooldown:** {cooldown_status}",
+ "**Target Required:** Yes"
+ ]
+ elif crime_type == "mugging":
+ description = [
+ f"**Success Rate:** {int(data['success_rate'] * 100)}%",
+ "**Reward:** 15-25% of target's balance (max 1500)",
+ f"**Risk Level:** {risk_emoji}",
+ f"**Cooldown:** {cooldown_status}",
+ "**Target Required:** Yes"
+ ]
+ else:
+ description = [
+ f"**Success Rate:** {int(data['success_rate'] * 100)}%",
+ f"**Reward:** {data['min_reward']:,} - {data['max_reward']:,}",
+ f"**Risk Level:** {risk_emoji}",
+ f"**Cooldown:** {cooldown_status}"
+ ]
+
+ return "\n".join(description)
+
+def calculate_streak_bonus(streak: int) -> float:
+ """Calculate reward multiplier based on current streak.
+
+ The bonus increases with streak but has diminishing returns:
+ Streak 1: +5% (1.05x)
+ Streak 2: +10% (1.10x)
+ Streak 3: +15% (1.15x)
+ Streak 4: +20% (1.20x)
+ Streak 5+: +25% (1.25x) max
+ """
+ if streak <= 0:
+ return 1.0
+
+ # Calculate bonus with diminishing returns
+ bonus = min(0.25, streak * 0.05) # Cap at 25% bonus
+ return 1.0 + bonus
+
+async def update_streak(config, member: discord.Member, success: bool) -> tuple[int, float]:
+ """Update a member's crime streak and return new streak and multiplier.
+
+ Args:
+ config: The config instance to use for data storage
+ member (discord.Member): The member to update
+ success (bool): Whether the crime was successful
+
+ Returns:
+ tuple[int, float]: The new streak count and reward multiplier
+ """
+ async with config.member(member).all() as member_data:
+ current_time = int(time.time())
+ last_crime = member_data.get("last_crime_time", 0)
+
+ # Reset streak if it's been more than 24 hours since last crime
+ if current_time - last_crime > 86400: # 24 hours in seconds
+ member_data["current_streak"] = 0
+
+ if success:
+ # Increment streak on success
+ member_data["current_streak"] += 1
+ # Update highest streak if current is higher
+ if member_data["current_streak"] > member_data.get("highest_streak", 0):
+ member_data["highest_streak"] = member_data["current_streak"]
+ else:
+ # Reset streak on failure
+ member_data["current_streak"] = 0
+
+ # Update last crime time
+ member_data["last_crime_time"] = current_time
+
+ # Calculate new multiplier
+ new_multiplier = calculate_streak_bonus(member_data["current_streak"])
+ member_data["streak_multiplier"] = new_multiplier
+
+ return member_data["current_streak"], new_multiplier
+
+def format_streak_text(streak: int, multiplier: float) -> str:
+ """Format streak information for display in embeds.
+
+ Args:
+ streak (int): Current streak count
+ multiplier (float): Current reward multiplier
+
+ Returns:
+ str: Formatted streak text
+ """
+ if streak <= 0:
+ return "No active streak"
+
+ bonus_percent = (multiplier - 1.0) * 100
+ return f"๐ฅ {streak} streak (+{bonus_percent:.0f}%)"
diff --git a/lootdrop/README.md b/lootdrop/README.md
new file mode 100644
index 0000000..2ccf8a6
--- /dev/null
+++ b/lootdrop/README.md
@@ -0,0 +1,78 @@
+# LootDrop Cog for Red-DiscordBot
+
+A fun and engaging cog that creates random loot drops in your Discord channels and threads. Users can claim rewards, build streaks, and participate in party drops with other members!
+
+## Features
+
+### Basic Functionality
+- Random loot drops appear in configured channels and threads
+- Customizable drop frequency and reward amounts
+- Support for both text channels and threads
+- Activity-based drops (only drops in active channels)
+- Bad outcomes add risk/reward gameplay
+
+### Streak System
+- Earn streak bonuses for consecutive successful claims
+- Configurable streak multipliers and timeouts
+- Compete for highest streaks on the leaderboard
+- Streaks reset on bad outcomes or timeout
+
+### Party Drops
+- Special drops that multiple users can claim
+- Scaled rewards based on reaction time:
+ - Super Fast (0-20%): 80-100% of max reward
+ - Fast (20-40%): 60-80% of max reward
+ - Medium (40-60%): 40-60% of max reward
+ - Slow (60-80%): 20-40% of max reward
+ - Very Slow (80-100%): minimum reward
+- Configurable party drop chance and timeout
+
+### Statistics & Leaderboard
+- Track successful and failed claims
+- View personal stats and streaks
+- Server-wide leaderboard
+- Rank tracking for competitive play
+
+## Commands
+
+### General Commands
+- `[p]lootdrop` - Show basic cog info
+- `[p]lootdrop stats [user]` - View loot drop statistics
+- `[p]lootdrop leaderboard` - View the server leaderboard
+- `[p]lootdrop settings` - View current settings
+
+### Admin Commands
+- `[p]lootdrop set toggle` - Enable/disable loot drops
+- `[p]lootdrop set addchannel ` - Add a channel or thread
+- `[p]lootdrop set removechannel ` - Remove a channel or thread
+- `[p]lootdrop set credits ` - Set credit range
+- `[p]lootdrop set badchance ` - Set bad outcome chance
+- `[p]lootdrop set timeout ` - Set claim timeout
+- `[p]lootdrop set frequency ` - Set drop frequency
+- `[p]lootdrop set activitytimeout ` - Set activity timeout
+- `[p]lootdrop set streakbonus ` - Set streak bonus
+- `[p]lootdrop set streakmax ` - Set maximum streak
+- `[p]lootdrop set streaktimeout ` - Set streak timeout
+- `[p]lootdrop set partychance ` - Set party drop chance
+- `[p]lootdrop set partycredits ` - Set party drop rewards
+- `[p]lootdrop set partytimeout ` - Set party claim timeout
+- `[p]lootdrop force [channel]` - Force a drop
+- `[p]lootdrop forceparty [channel]` - Force a party drop
+
+## Installation
+
+1. Make sure you have Red-DiscordBot V3 installed
+2. Add this repository: `[p]repo add CalaMari-Cogs https://github.com/CalaMariGold/CalaMari-Cogs`
+3. Install the cog: `[p]cog install CalaMari-Cogs lootdrop`
+
+## Setup
+
+1. Load the cog: `[p]load lootdrop`
+2. Add channels/threads: `[p]lootdrop set addchannel `
+3. Enable drops: `[p]lootdrop set toggle`
+4. Customize settings as desired using the `[p]lootdrop set` commands
+
+## Support
+
+If you encounter any issues or have suggestions, please open an issue on the GitHub repository.
+
diff --git a/lootdrop/__init__.py b/lootdrop/__init__.py
new file mode 100644
index 0000000..7511e20
--- /dev/null
+++ b/lootdrop/__init__.py
@@ -0,0 +1,10 @@
+"""
+LootDrop cog for Red-DiscordBot by CalaMariGold
+Random credit drops with good/bad outcomes
+"""
+from .lootdrop import LootDrop
+
+__red_end_user_data_statement__ = "This cog stores user interaction cooldowns and statistics. No personal data is stored."
+
+async def setup(bot):
+ await bot.add_cog(LootDrop(bot))
diff --git a/lootdrop/info.json b/lootdrop/info.json
new file mode 100644
index 0000000..377b92e
--- /dev/null
+++ b/lootdrop/info.json
@@ -0,0 +1,16 @@
+{
+ "name": "LootDrop",
+ "short": "Interactive random resource drops with risk/reward mechanics",
+ "description": "A dynamic resource distribution system that creates random drops in configured channels and threads. Features include streak bonuses, party drops for multiple users, risk/reward mechanics with good/bad outcomes, leaderboards, and detailed statistics tracking.",
+ "end_user_data_statement": "This cog stores user data including:\n- Drop statistics (successful/failed claims)\n- Streak information and history\n- Last claim timestamps for streak tracking\n- Highest achieved streaks\nAll data can be removed using `[p]lootdrop wipestats` or the standard Red delete data commands.",
+ "install_msg": "Thanks for installing LootDrop! Get started with `[p]lootdrop set addchannel` to configure channels, then `[p]lootdrop set toggle` to enable drops.\n\nNotes:\n- Uses Config to store user stats and settings\n- Moderate memory usage for tracking active drops and streaks\n- Requires bank to be registered (`[p]bank register`)\n- Requires manage_messages permission for button interactions\n\nUse `[p]help Lootdrop` for all commands.",
+ "author": ["CalaMariGold"],
+ "required_cogs": {},
+ "requirements": [],
+ "tags": ["economy", "fun", "games"],
+ "min_bot_version": "3.5.0",
+ "min_python_version": [3, 8, 1],
+ "hidden": false,
+ "disabled": false,
+ "type": "COG"
+}
diff --git a/lootdrop/lootdrop.py b/lootdrop/lootdrop.py
new file mode 100644
index 0000000..ea469d1
--- /dev/null
+++ b/lootdrop/lootdrop.py
@@ -0,0 +1,1289 @@
+"""LootDrop cog for Red-DiscordBot - Drop random loot in channels for users to grab"""
+from typing import Optional, Dict, List, Union, Tuple, Any, cast
+import discord
+from redbot.core import commands, Config, bank
+from redbot.core.bot import Red
+from redbot.core.utils.chat_formatting import box, humanize_list
+import datetime
+import random
+from discord.ext import tasks
+from collections import defaultdict
+import asyncio
+from .scenarios import SCENARIOS, Scenario
+import time
+
+# Default guild settings
+DEFAULT_GUILD_SETTINGS: Dict[str, Any] = {
+ "enabled": False,
+ "channels": [],
+ "min_credits": 100,
+ "max_credits": 1000,
+ "bad_outcome_chance": 30,
+ "drop_timeout": 60,
+ "min_frequency": 300,
+ "max_frequency": 1800,
+ "activity_timeout": 300,
+ "last_drop": 0,
+ "next_drop": 0,
+ "messages": {"expired": "The opportunity has passed..."},
+ "user_stats": {}, # {user_id: {"good": count, "bad": count}}
+ "streak_bonus": 10, # Percentage bonus per streak level
+ "streak_max": 5, # Maximum streak multiplier
+ "streak_timeout": 24, # Hours before streak resets
+ "party_drop_chance": 5, # Percentage chance for party drop
+ "party_drop_min": 50, # Min credits per person
+ "party_drop_max": 200, # Max credits per person
+ "party_drop_timeout": 30 # Seconds to claim party drop
+}
+
+
+class ActiveDrop:
+ """Represents an active loot drop in a channel
+
+ Attributes
+ ----------
+ message: discord.Message
+ The message containing the loot drop
+ view: discord.ui.View
+ The view containing the claim button
+ created: int
+ Unix timestamp of when the drop was created
+ """
+ def __init__(self, message: discord.Message, view: discord.ui.View, created: int) -> None:
+ self.message: discord.Message = message
+ self.view: discord.ui.View = view
+ self.created: int = created
+
+
+class LootDrop(commands.Cog):
+ """Drop random loot in channels for users to grab
+
+ This cog creates random loot drops in active channels that users can claim.
+ It supports both regular drops (single claim) and party drops (multi-claim).
+
+ Attributes
+ ----------
+ bot: Red
+ The Red instance
+ config: Config
+ The config manager for guild settings
+ active_drops: Dict[int, ActiveDrop]
+ Currently active drops, keyed by guild ID
+ tasks: Dict[int, asyncio.Task]
+ Active timeout tasks, keyed by guild ID
+ channel_last_message: Dict[int, int]
+ Last message timestamp per channel
+ channel_perms_cache: Dict[int, Tuple[int, bool]]
+ Cache of channel permission checks
+ """
+
+ def __init__(self, bot: Red) -> None:
+ self.bot: Red = bot
+ self.config: Config = Config.get_conf(
+ self,
+ identifier=582650109,
+ force_registration=True
+ )
+
+ self.config.register_guild(**DEFAULT_GUILD_SETTINGS)
+
+ self.active_drops: Dict[int, ActiveDrop] = {}
+ self.tasks: Dict[int, asyncio.Task] = {}
+ self.channel_last_message: Dict[int, int] = defaultdict(lambda: 0)
+ self.channel_perms_cache: Dict[int, Tuple[int, bool]] = {}
+
+ self.start_drops.start()
+
+ def cog_unload(self) -> None:
+ """Cleanup when cog is unloaded
+
+ Cancels all tasks and removes active drops
+ """
+ # Stop the background task first
+ self.start_drops.cancel()
+
+ try:
+ # Cancel all timeout tasks
+ for task in self.tasks.values():
+ task.cancel()
+
+ # Clean up active drops
+ for drop in self.active_drops.values():
+ try:
+ self.bot.loop.create_task(drop.message.delete())
+ except:
+ pass
+ finally:
+ # Clear all state
+ self.active_drops.clear()
+ self.tasks.clear()
+ self.channel_last_message.clear()
+ self.channel_perms_cache.clear()
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Track channel activity"""
+ if message.guild and not message.author.bot:
+ # Update activity for both channels and threads
+ self.channel_last_message[message.channel.id] = int(datetime.datetime.now().timestamp())
+
+ async def channel_is_active(self, channel: Union[discord.TextChannel, discord.Thread]) -> bool:
+ """Check if a channel has had message activity within the activity timeout"""
+ now: int = int(datetime.datetime.now().timestamp())
+ last_message: int = self.channel_last_message[channel.id]
+ timeout: int = await self.config.guild(channel.guild).activity_timeout()
+
+ # For threads, also check if they're still active
+ if isinstance(channel, discord.Thread) and not channel.parent:
+ return False
+
+ return (now - last_message) < timeout
+
+ async def has_channel_permissions(self, channel: Union[discord.TextChannel, discord.Thread]) -> bool:
+ """Check if bot has required permissions in channel, with caching"""
+ now: int = int(datetime.datetime.now().timestamp())
+ cache_entry: Optional[Tuple[int, bool]] = self.channel_perms_cache.get(channel.id)
+
+ if cache_entry and (now - cache_entry[0]) < 300:
+ return cache_entry[1]
+
+ # For threads, check parent channel permissions too
+ if isinstance(channel, discord.Thread):
+ if not channel.parent:
+ return False
+ parent_perms = channel.parent.permissions_for(channel.guild.me)
+ if not parent_perms.send_messages:
+ return False
+
+ has_perms: bool = channel.permissions_for(channel.guild.me).send_messages
+ self.channel_perms_cache[channel.id] = (now, has_perms)
+ return has_perms
+
+ async def get_active_channel(self, guild: discord.Guild) -> Optional[Union[discord.TextChannel, discord.Thread]]:
+ """Get a random active channel from the configured channels"""
+ channels: List[int] = await self.config.guild(guild).channels()
+ active_channels: List[Union[discord.TextChannel, discord.Thread]] = []
+
+ for channel_id in channels:
+ channel = None
+ # Try to get as text channel first
+ channel = guild.get_channel(channel_id)
+ # If not found, try to get as thread
+ if not channel:
+ for thread in guild.threads:
+ if thread.id == channel_id:
+ channel = thread
+ break
+
+ if channel and await self.channel_is_active(channel) and await self.has_channel_permissions(channel):
+ active_channels.append(channel)
+
+ return random.choice(active_channels) if active_channels else None
+
+ @tasks.loop(seconds=30)
+ async def start_drops(self) -> None:
+ """Check for and create drops in all guilds"""
+ for guild in self.bot.guilds:
+ try:
+ if not await self.config.guild(guild).enabled() or guild.id in self.active_drops:
+ continue
+
+ now: int = int(datetime.datetime.now().timestamp())
+ if now >= await self.config.guild(guild).next_drop():
+ if channel := await self.get_active_channel(guild):
+ await self.create_drop(channel)
+ await self.schedule_next_drop(guild)
+ except Exception as e:
+ if guild.system_channel and guild.system_channel.permissions_for(guild.me).send_messages:
+ await guild.system_channel.send(f"Error creating loot drop: {e}")
+
+ @start_drops.before_loop
+ async def before_start_drops(self) -> None:
+ """Wait for bot to be ready before starting drops"""
+ await self.bot.wait_until_ready()
+
+ async def schedule_next_drop(self, guild: discord.Guild) -> None:
+ """Schedule the next drop for a guild"""
+ if not await self.config.guild(guild).enabled():
+ return
+
+ min_freq: int = await self.config.guild(guild).min_frequency()
+ max_freq: int = await self.config.guild(guild).max_frequency()
+ now: int = int(datetime.datetime.now().timestamp())
+
+ await self.config.guild(guild).next_drop.set(now + random.randint(min_freq, max_freq))
+
+ async def create_drop(self, channel: Union[discord.TextChannel, discord.Thread]) -> None:
+ """Create a new loot drop in the specified channel"""
+ if not channel.permissions_for(channel.guild.me).send_messages:
+ return
+
+ if channel.guild.id in self.active_drops:
+ return
+
+ # Check for party drop
+ is_party = random.randint(1, 100) <= await self.config.guild(channel.guild).party_drop_chance()
+
+ if is_party:
+ await self.create_party_drop(channel)
+ else:
+ scenario = random.choice(SCENARIOS)
+ timeout = await self.config.guild(channel.guild).drop_timeout()
+ view = LootDropView(self, scenario, float(timeout))
+ view.message = await channel.send(scenario["start"], view=view)
+
+ self.active_drops[channel.guild.id] = ActiveDrop(
+ message=view.message,
+ view=view,
+ created=int(datetime.datetime.now().timestamp())
+ )
+
+ # Start timeout task
+ self.tasks[channel.guild.id] = asyncio.create_task(self._handle_drop_timeout(channel.guild.id, timeout))
+
+ async def _handle_drop_timeout(self, guild_id: int, timeout: int) -> None:
+ """Handle drop timeout and cleanup"""
+ try:
+ if guild_id in self.active_drops:
+ drop = self.active_drops[guild_id]
+ # Calculate remaining time based on when the drop was created
+ now = int(datetime.datetime.now().timestamp())
+ elapsed = now - drop.created
+ remaining = max(0, timeout - elapsed)
+
+ await asyncio.sleep(remaining)
+ if guild_id in self.active_drops:
+ drop = self.active_drops[guild_id]
+ if not drop.view.claimed:
+ await drop.message.edit(content="The opportunity has passed...", view=None)
+ del self.active_drops[guild_id]
+ except Exception:
+ pass
+ finally:
+ if guild_id in self.tasks:
+ del self.tasks[guild_id]
+
+ async def create_party_drop(self, channel: Union[discord.TextChannel, discord.Thread]) -> None:
+ """Create a party drop that everyone can claim
+
+ Parameters
+ ----------
+ channel: Union[discord.TextChannel, discord.Thread]
+ The channel to create the drop in
+ """
+ if channel.guild.id in self.active_drops:
+ return
+
+ timeout = await self.config.guild(channel.guild).party_drop_timeout()
+ view = PartyDropView(self, timeout)
+ message = await channel.send(
+ "๐ **PARTY DROP!** ๐\n"
+ "Everyone who clicks the button in the next "
+ f"{timeout} seconds gets a prize!\n",
+ view=view
+ )
+ view.message = message
+
+ self.active_drops[channel.guild.id] = ActiveDrop(
+ message=message,
+ view=view,
+ created=int(datetime.datetime.now().timestamp())
+ )
+
+ # Start timeout task
+ self.tasks[channel.guild.id] = asyncio.create_task(self._handle_party_timeout(channel.guild.id, timeout))
+
+ async def _handle_party_timeout(self, guild_id: int, timeout: int) -> None:
+ """Handle party drop timeout and rewards
+
+ Parameters
+ ----------
+ guild_id: int
+ The guild ID for this party drop
+ timeout: int
+ Seconds to wait before processing rewards
+
+ Notes
+ -----
+ Rewards are scaled based on reaction time:
+ - Clicking within first 20% of timeout: 80-100% of max credits
+ - Clicking within 20-40% of timeout: 60-80% of max credits
+ - Clicking within 40-60% of timeout: 40-60% of max credits
+ - Clicking within 60-80% of timeout: 20-40% of max credits
+ - Clicking within 80-100% of timeout: min credits
+ """
+ try:
+ await asyncio.sleep(timeout)
+
+ if guild_id in self.active_drops:
+ drop = self.active_drops[guild_id]
+ view = cast(PartyDropView, drop.view)
+
+ if not view.claimed_users:
+ await drop.message.edit(content="No one joined the party... ๐ข", view=None)
+ else:
+ min_credits = await self.config.guild(drop.message.guild).party_drop_min()
+ max_credits = await self.config.guild(drop.message.guild).party_drop_max()
+ credit_range = max_credits - min_credits
+
+ # Sort users by click speed
+ sorted_users = sorted(view.claimed_users.items(), key=lambda x: x[1])
+
+ results = []
+ for position, (user_id, claim_time) in enumerate(sorted_users, 1):
+ user = drop.message.guild.get_member(int(user_id))
+ if user:
+ # Calculate reaction time and percentage of timeout
+ reaction_time = claim_time - view.start_time
+ time_percentage = (reaction_time / timeout) * 100
+
+ # Calculate credits based on reaction time
+ if time_percentage <= 20: # Super fast (80-100% of max)
+ credits = max_credits - int((time_percentage / 20) * (credit_range * 0.2))
+ elif time_percentage <= 40: # Fast (60-80% of max)
+ credits = int(max_credits * 0.8) - int(((time_percentage - 20) / 20) * (credit_range * 0.2))
+ elif time_percentage <= 60: # Medium (40-60% of max)
+ credits = int(max_credits * 0.6) - int(((time_percentage - 40) / 20) * (credit_range * 0.2))
+ elif time_percentage <= 80: # Slow (20-40% of max)
+ credits = int(max_credits * 0.4) - int(((time_percentage - 60) / 20) * (credit_range * 0.2))
+ else: # Very slow (min credits)
+ credits = min_credits
+
+ await bank.deposit_credits(user, credits)
+
+ # Update stats and streak
+ async with self.config.guild(drop.message.guild).user_stats() as stats:
+ if user_id not in stats:
+ stats[user_id] = {"good": 0, "bad": 0, "streak": 0, "highest_streak": 0, "last_claim": 0}
+
+ stats[user_id]["good"] += 1
+
+ # Update streak if within timeout
+ now = int(datetime.datetime.now().timestamp())
+ streak_timeout = await self.config.guild(drop.message.guild).streak_timeout()
+ if now - stats[user_id]["last_claim"] < streak_timeout * 3600:
+ stats[user_id]["streak"] += 1
+ stats[user_id]["highest_streak"] = max(stats[user_id]["streak"], stats[user_id]["highest_streak"])
+ else:
+ stats[user_id]["streak"] = 1
+
+ stats[user_id]["last_claim"] = now
+
+ # Add result with streak and timing info
+ streak_display = f" (๐ฅ{stats[user_id]['streak']})" if stats[user_id]['streak'] > 1 else ""
+ position_emoji = ["๐ฅ", "๐ฅ", "๐ฅ"][position-1] if position <= 3 else f"{position}th"
+
+ # Add speed indicator based on time percentage
+ if time_percentage <= 20:
+ speed = "๐จ Super Fast!"
+ elif time_percentage <= 40:
+ speed = "โก Fast!"
+ elif time_percentage <= 60:
+ speed = "๐ Good"
+ elif time_percentage <= 80:
+ speed = "๐ Slow"
+ else:
+ speed = "๐ฆฅ Very Slow"
+
+ currency_name = await bank.get_currency_name(drop.message.guild)
+ results.append(
+ f"{position_emoji} {user.mention}{streak_display}: {credits:,} {currency_name}\n"
+ f"โ {speed} ({reaction_time:.2f}s)"
+ )
+
+ await drop.message.edit(
+ content=f"๐ **Party Drop Results!** ๐\n"
+ f"{len(results)} party-goers claimed rewards:\n\n" +
+ "\n".join(results),
+ view=None
+ )
+
+ del self.active_drops[guild_id]
+
+ except Exception as e:
+ if guild_id in self.active_drops:
+ try:
+ await self.active_drops[guild_id].message.edit(content=f"Error processing party drop: {e}", view=None)
+ except:
+ pass
+ del self.active_drops[guild_id]
+ finally:
+ if guild_id in self.tasks:
+ del self.tasks[guild_id]
+
+ async def process_loot_claim(self, interaction: discord.Interaction) -> None:
+ """Process a user's loot claim"""
+ try:
+ guild: discord.Guild = interaction.guild
+ user: discord.Member = interaction.user
+
+ min_credits: int = await self.config.guild(guild).min_credits()
+ max_credits: int = await self.config.guild(guild).max_credits()
+ bad_chance: int = await self.config.guild(guild).bad_outcome_chance()
+ streak_bonus: int = await self.config.guild(guild).streak_bonus()
+ streak_max: int = await self.config.guild(guild).streak_max()
+ streak_timeout: int = await self.config.guild(guild).streak_timeout()
+ currency_name: str = await bank.get_currency_name(guild)
+
+ scenario: Scenario = next(
+ (s for s in SCENARIOS if s["start"] == interaction.message.content),
+ SCENARIOS[0]
+ )
+
+ amount: int = random.randint(min_credits, max_credits)
+ is_bad: bool = random.randint(1, 100) <= bad_chance
+
+ # Update user stats and streaks
+ async with self.config.guild(guild).user_stats() as stats:
+ now = int(datetime.datetime.now().timestamp())
+ user_stats = stats.setdefault(str(user.id), {
+ "good": 0,
+ "bad": 0,
+ "streak": 0,
+ "last_claim": 0,
+ "highest_streak": 0
+ })
+
+ # Check if streak should reset due to timeout
+ hours_since_last = (now - user_stats.get("last_claim", 0)) / 3600
+ if hours_since_last > streak_timeout:
+ user_stats["streak"] = 0
+
+ user_stats["last_claim"] = now
+
+ try:
+ if is_bad:
+ amount = min(amount, await bank.get_balance(user))
+ await bank.withdraw_credits(user, amount)
+ message: str = scenario["bad"].format(user=user.mention, amount=f"{amount:,}", currency=currency_name)
+ user_stats["bad"] += 1
+ user_stats["streak"] = 0
+ else:
+ # Calculate streak bonus
+ streak = min(user_stats["streak"], streak_max)
+ bonus = int(amount * (streak * streak_bonus / 100))
+ total = amount + bonus
+
+ await bank.deposit_credits(user, total)
+ if bonus > 0:
+ message: str = (
+ f"{scenario['good'].format(user=user.mention, amount=f'{total:,}', currency=currency_name)}\n"
+ f"(Base: {amount:,} + Streak Bonus: {bonus:,} [{streak}x])"
+ )
+ else:
+ message: str = scenario["good"].format(user=user.mention, amount=f"{total:,}", currency=currency_name)
+
+ user_stats["good"] += 1
+ user_stats["streak"] += 1
+ user_stats["highest_streak"] = max(user_stats["streak"], user_stats.get("highest_streak", 0))
+
+ # Add stats to message
+ message += f"\n-# (โ
{user_stats['good']} | โ {user_stats.get('bad', 0)} drops claimed | "
+ if user_stats["streak"] > 0:
+ message += f"๐ฅ {user_stats['streak']} streak"
+ else:
+ message += "โ๏ธ streak lost"
+ position, _ = await self.get_leaderboard_position(guild, str(user.id))
+ message += f" | Rank #{position})"
+
+ # Use followup instead of response since we might have already responded
+ if interaction.response.is_done():
+ await interaction.followup.send(message)
+ else:
+ await interaction.response.send_message(message)
+
+ except Exception as e:
+ if interaction.response.is_done():
+ await interaction.followup.send(f"Error processing claim: {e}", ephemeral=True)
+ else:
+ await interaction.response.send_message(f"Error processing claim: {e}", ephemeral=True)
+
+ except Exception as e:
+ if interaction.response.is_done():
+ await interaction.followup.send(f"Error processing claim: {e}", ephemeral=True)
+ else:
+ await interaction.response.send_message(f"Error processing claim: {e}", ephemeral=True)
+
+ @commands.group()
+ @commands.guild_only()
+ async def lootdrop(self, ctx: commands.Context) -> None:
+ """Random loot drops that appear in active channels
+
+ Drops can be claimed by clicking a button. Features include:
+ - Regular drops (single claim)
+ - Party drops (everyone can claim)
+ - Streak bonuses for consecutive claims
+ - Risk/reward mechanics with good/bad outcomes
+ - Leaderboards and statistics
+
+ Use `[p]lootdrop set` to configure settings
+ Use `[p]lootdrop stats` to view your stats
+ Use `[p]lootdrop leaderboard` to view top claimers
+ """
+ pass
+
+ @lootdrop.group(name="set")
+ @commands.admin_or_permissions(administrator=True)
+ async def lootdrop_set(self, ctx: commands.Context) -> None:
+ """Configure LootDrop settings
+
+ All settings are per-server. Available settings:
+ - Toggle drops on/off
+ - Add/remove channels
+ - Credit amounts
+ - Drop frequency and timeouts
+ - Streak bonuses and timeouts
+ - Party drop settings
+
+ Use `[p]help LootDrop set` to see all settings
+ """
+ pass
+
+ @lootdrop_set.command(name="toggle")
+ async def lootdrop_set_toggle(self, ctx: commands.Context, on_off: Optional[bool] = None) -> None:
+ """Toggle LootDrop on/off"""
+ curr_state: bool = await self.config.guild(ctx.guild).enabled()
+ if on_off is None:
+ on_off = not curr_state
+
+ await self.config.guild(ctx.guild).enabled.set(on_off)
+ if on_off:
+ await self.schedule_next_drop(ctx.guild)
+ await ctx.send("LootDrop is now enabled! Drops will begin shortly.")
+ else:
+ await ctx.send("LootDrop is now disabled.")
+
+ @lootdrop_set.command(name="addchannel")
+ async def lootdrop_set_addchannel(
+ self,
+ ctx: commands.Context,
+ channel: Union[discord.TextChannel, discord.Thread]
+ ) -> None:
+ """Add a channel or thread to the loot drop pool"""
+ if isinstance(channel, discord.Thread) and not channel.parent:
+ await ctx.send("That thread no longer exists!")
+ return
+
+ if not channel.permissions_for(ctx.guild.me).send_messages:
+ await ctx.send("I don't have permission to send messages there!")
+ return
+
+ async with self.config.guild(ctx.guild).channels() as channels:
+ if channel.id in channels:
+ await ctx.send(f"{channel.mention} is already in the loot drop pool!")
+ return
+
+ channels.append(channel.id)
+
+ await ctx.send(f"Added {channel.mention} to the loot drop pool!")
+
+ @lootdrop_set.command(name="removechannel")
+ async def lootdrop_set_removechannel(
+ self,
+ ctx: commands.Context,
+ channel: Union[discord.TextChannel, discord.Thread]
+ ) -> None:
+ """Remove a channel or thread from the loot drop pool"""
+ async with self.config.guild(ctx.guild).channels() as channels:
+ if channel.id not in channels:
+ await ctx.send(f"{channel.mention} is not in the loot drop pool!")
+ return
+
+ channels.remove(channel.id)
+
+ await ctx.send(f"Removed {channel.mention} from the loot drop pool!")
+
+ @lootdrop_set.command(name="credits")
+ async def lootdrop_set_credits(self, ctx: commands.Context, min_credits: int, max_credits: int) -> None:
+ """Set the credit range for drops"""
+ currency_name = await bank.get_currency_name(ctx.guild)
+
+ if min_credits < 1:
+ await ctx.send(f"Minimum {currency_name} must be at least 1!")
+ return
+
+ if max_credits < min_credits:
+ await ctx.send(f"Maximum {currency_name} must be greater than minimum {currency_name}!")
+ return
+
+ await self.config.guild(ctx.guild).min_credits.set(min_credits)
+ await self.config.guild(ctx.guild).max_credits.set(max_credits)
+ await ctx.send(f"Drops will now give between {min_credits:,} and {max_credits:,} {currency_name}!")
+
+ @lootdrop_set.command(name="badchance")
+ async def lootdrop_set_badchance(self, ctx: commands.Context, chance: int) -> None:
+ """Set the chance of bad outcomes (0-100)"""
+ if not 0 <= chance <= 100:
+ await ctx.send("Chance must be between 0 and 100!")
+ return
+
+ await self.config.guild(ctx.guild).bad_outcome_chance.set(chance)
+ await ctx.send(f"Bad outcome chance set to {chance}%")
+
+ @lootdrop_set.command(name="timeout")
+ async def lootdrop_set_timeout(self, ctx: commands.Context, seconds: int) -> None:
+ """Set how long users have to claim a drop
+
+ Parameters
+ ----------
+ seconds: int
+ Number of seconds before the drop expires
+ Example: 3600 = 1 hour, 1800 = 30 minutes
+ """
+ if seconds < 10:
+ await ctx.send("Timeout must be at least 10 seconds!")
+ return
+
+ await self.config.guild(ctx.guild).drop_timeout.set(seconds)
+
+ # Convert to a more readable format for the response
+ if seconds >= 3600:
+ time_str = f"{seconds // 3600} hours"
+ if seconds % 3600:
+ time_str += f" and {(seconds % 3600) // 60} minutes"
+ elif seconds >= 60:
+ time_str = f"{seconds // 60} minutes"
+ if seconds % 60:
+ time_str += f" and {seconds % 60} seconds"
+ else:
+ time_str = f"{seconds} seconds"
+
+ await ctx.send(f"Users will now have {time_str} to claim drops!")
+
+ @lootdrop_set.command(name="frequency")
+ async def lootdrop_set_frequency(self, ctx: commands.Context, min_minutes: int, max_minutes: int) -> None:
+ """Set how frequently drops appear"""
+ if min_minutes < 1:
+ await ctx.send("Minimum frequency must be at least 1 minute!")
+ return
+
+ if max_minutes < min_minutes:
+ await ctx.send("Maximum frequency must be greater than minimum frequency!")
+ return
+
+ await self.config.guild(ctx.guild).min_frequency.set(min_minutes * 60)
+ await self.config.guild(ctx.guild).max_frequency.set(max_minutes * 60)
+ await self.schedule_next_drop(ctx.guild)
+ await ctx.send(f"Drops will occur randomly between {min_minutes} and {max_minutes} minutes apart.")
+
+ @lootdrop_set.command(name="activitytimeout")
+ async def lootdrop_set_activitytimeout(self, ctx: commands.Context, minutes: int) -> None:
+ """Set how long a channel can be inactive before drops are skipped"""
+ if minutes < 1:
+ await ctx.send("Timeout must be at least 1 minute!")
+ return
+
+ await self.config.guild(ctx.guild).activity_timeout.set(minutes * 60)
+ await ctx.send(f"Channels will now be considered inactive after {minutes} minutes without messages.")
+
+ @lootdrop_set.command(name="streakbonus")
+ async def lootdrop_set_streakbonus(self, ctx: commands.Context, percentage: int) -> None:
+ """Set the credit bonus percentage per streak level
+
+ Example: A bonus of 10 means each streak level adds 10% more credits
+ So streak 3 would give a 30% bonus
+ """
+ if percentage < 0:
+ await ctx.send("Bonus percentage must be positive!")
+ return
+
+ await self.config.guild(ctx.guild).streak_bonus.set(percentage)
+ await ctx.send(f"Streak bonus set to {percentage}% per level")
+
+ @lootdrop_set.command(name="streakmax")
+ async def lootdrop_set_streakmax(self, ctx: commands.Context, max_level: int) -> None:
+ """Set the maximum streak multiplier level
+
+ Example: A max of 5 means streaks cap at 5x the bonus
+ """
+ if max_level < 1:
+ await ctx.send("Maximum streak level must be at least 1!")
+ return
+
+ await self.config.guild(ctx.guild).streak_max.set(max_level)
+ await ctx.send(f"Maximum streak level set to {max_level}")
+
+ @lootdrop_set.command(name="streaktimeout")
+ async def lootdrop_set_streaktimeout(self, ctx: commands.Context, hours: int) -> None:
+ """Set how many hours before a streak resets
+
+ Example: A timeout of 24 means you must claim within 24 hours to keep streak
+ """
+ if hours < 1:
+ await ctx.send("Timeout must be at least 1 hour!")
+ return
+
+ await self.config.guild(ctx.guild).streak_timeout.set(hours)
+ await ctx.send(f"Streak timeout set to {hours} hours")
+
+ @lootdrop_set.command(name="partychance")
+ async def lootdrop_set_partychance(self, ctx: commands.Context, chance: int) -> None:
+ """Set the chance of a party drop appearing (0-100)
+
+ Party drops allow everyone to claim within a time limit
+ """
+ if not 0 <= chance <= 100:
+ await ctx.send("Chance must be between 0 and 100!")
+ return
+
+ await self.config.guild(ctx.guild).party_drop_chance.set(chance)
+ await ctx.send(f"Party drop chance set to {chance}%")
+
+ @lootdrop_set.command(name="partycredits")
+ async def lootdrop_set_partycredits(self, ctx: commands.Context, min_credits: int, max_credits: int) -> None:
+ """Set the credit range for party drops
+
+ Each person who claims gets this amount
+ """
+ currency_name = await bank.get_currency_name(ctx.guild)
+
+ if min_credits < 1:
+ await ctx.send(f"Minimum {currency_name} must be at least 1!")
+ return
+
+ if max_credits < min_credits:
+ await ctx.send(f"Maximum {currency_name} must be greater than minimum {currency_name}!")
+ return
+
+ await self.config.guild(ctx.guild).party_drop_min.set(min_credits)
+ await self.config.guild(ctx.guild).party_drop_max.set(max_credits)
+ await ctx.send(f"Party drops will now give between {min_credits:,} and {max_credits:,} {currency_name} per person!")
+
+ @lootdrop_set.command(name="partytimeout")
+ async def lootdrop_set_partytimeout(self, ctx: commands.Context, seconds: int) -> None:
+ """Set how long users have to claim a party drop"""
+ if seconds < 5:
+ await ctx.send("Timeout must be at least 5 seconds!")
+ return
+
+ await self.config.guild(ctx.guild).party_drop_timeout.set(seconds)
+ await ctx.send(f"Users will now have {seconds} seconds to claim party drops!")
+
+ @commands.is_owner()
+ @lootdrop.command(name="wipedata")
+ async def lootdrop_wipedata(self, ctx: commands.Context) -> None:
+ """โ ๏ธ [Owner Only] Completely wipe all LootDrop data
+
+ This will erase:
+ - All guild settings
+ - All user stats and streaks
+ - All active drops
+
+ This action cannot be undone!
+ """
+ # Ask for confirmation
+ msg = await ctx.send(
+ "โ ๏ธ **WARNING**: This will completely wipe all LootDrop data across all servers!\n"
+ "This includes all settings, stats, and streaks.\n\n"
+ "**This action cannot be undone!**\n\n"
+ "Type `yes, wipe all data` to confirm."
+ )
+
+ try:
+ response = await self.bot.wait_for(
+ "message",
+ timeout=30.0,
+ check=lambda m: m.author == ctx.author and m.channel == ctx.channel
+ )
+ except asyncio.TimeoutError:
+ await msg.edit(content="Data wipe cancelled - timed out.")
+ return
+
+ if response.content.lower() != "yes, wipe all data":
+ await msg.edit(content="Data wipe cancelled - incorrect confirmation.")
+ return
+
+ # Stop all active drops and tasks
+ self.start_drops.cancel()
+ for task in self.tasks.values():
+ task.cancel()
+
+ try:
+ # Clean up active drops
+ for drop in self.active_drops.values():
+ try:
+ await drop.message.delete()
+ except:
+ pass
+
+ # Clear all state
+ self.active_drops.clear()
+ self.tasks.clear()
+ self.channel_last_message.clear()
+ self.channel_perms_cache.clear()
+
+ # Clear all data from config
+ await self.config.clear_all()
+
+ # Restart the drop task
+ self.start_drops.start()
+
+ await msg.edit(content="โ
All LootDrop data has been wiped!")
+
+ except Exception as e:
+ await msg.edit(content=f"Error wiping data: {e}")
+
+ @commands.is_owner()
+ @lootdrop.command(name="wipestats")
+ async def lootdrop_wipestats(self, ctx: commands.Context) -> None:
+ """โ ๏ธ [Owner Only] Wipe all user stats and streaks
+
+ This will erase:
+ - All user stats (good/bad drops)
+ - All user streaks
+ - Leaderboard data
+
+ This action cannot be undone!
+ Settings and active drops will be preserved.
+ """
+ # Ask for confirmation
+ msg = await ctx.send(
+ "โ ๏ธ **WARNING**: This will wipe all user stats and streaks across all servers!\n"
+ "This includes all drop counts, streaks, and leaderboard data.\n\n"
+ "**This action cannot be undone!**\n"
+ "Server settings and active drops will be preserved.\n\n"
+ "Type `yes, wipe stats` to confirm."
+ )
+
+ try:
+ response = await self.bot.wait_for(
+ "message",
+ timeout=30.0,
+ check=lambda m: m.author == ctx.author and m.channel == ctx.channel
+ )
+ except asyncio.TimeoutError:
+ await msg.edit(content="Stats wipe cancelled - timed out.")
+ return
+
+ if response.content.lower() != "yes, wipe stats":
+ await msg.edit(content="Stats wipe cancelled - incorrect confirmation.")
+ return
+
+ try:
+ # Get all guild data
+ all_guilds = await self.config.all_guilds()
+
+ # Clear only user_stats for each guild
+ for guild_id in all_guilds:
+ async with self.config.guild_from_id(guild_id).user_stats() as stats:
+ stats.clear()
+
+ await msg.edit(content="โ
All user stats and streaks have been wiped!")
+
+ except Exception as e:
+ await msg.edit(content=f"Error wiping stats: {e}")
+
+ @lootdrop.command(name="force")
+ @commands.admin_or_permissions(administrator=True)
+ async def lootdrop_force(self, ctx: commands.Context, channel: Optional[Union[discord.TextChannel, discord.Thread]] = None) -> None:
+ """Force a lootdrop to appear"""
+ channel = channel or ctx.channel
+
+ if channel.guild.id in self.active_drops:
+ await ctx.send("There's already an active drop in this server! Wait for it to be claimed or expire.")
+ return
+
+ if not channel.permissions_for(channel.guild.me).send_messages:
+ await ctx.send(f"I don't have permission to send messages in {channel.mention}!")
+ return
+
+ try:
+ await self.create_drop(channel)
+ await self.config.guild(channel.guild).last_drop.set(int(datetime.datetime.now().timestamp()))
+ except Exception as e:
+ await ctx.send(f"Error creating drop: {e}")
+
+ @lootdrop.command(name="forceparty")
+ @commands.admin_or_permissions(administrator=True)
+ async def lootdrop_force_party(self, ctx: commands.Context, channel: Optional[Union[discord.TextChannel, discord.Thread]] = None) -> None:
+ """Force a party drop to appear
+
+ Everyone who clicks the button gets credits!
+ """
+ channel = channel or ctx.channel
+
+ if channel.guild.id in self.active_drops:
+ await ctx.send("There's already an active drop in this server! Wait for it to be claimed or expire.")
+ return
+
+ if not channel.permissions_for(channel.guild.me).send_messages:
+ await ctx.send(f"I don't have permission to send messages in {channel.mention}!")
+ return
+
+ try:
+ await self.create_party_drop(channel)
+ await self.config.guild(channel.guild).last_drop.set(int(datetime.datetime.now().timestamp()))
+ except Exception as e:
+ await ctx.send(f"Error creating party drop: {e}")
+
+ @lootdrop.command(name="stats")
+ async def lootdrop_stats(self, ctx: commands.Context, user: Optional[discord.Member] = None) -> None:
+ """View loot drop statistics for a user"""
+ user = user or ctx.author
+ stats = await self.config.guild(ctx.guild).user_stats()
+ user_stats = stats.get(str(user.id), {
+ "good": 0,
+ "bad": 0,
+ "streak": 0,
+ "highest_streak": 0
+ })
+
+ total = user_stats["good"] + user_stats.get("bad", 0)
+ if total == 0:
+ await ctx.send(f"{user.mention} hasn't claimed any drops yet!")
+ return
+
+ success_rate = (user_stats["good"] / total) * 100
+ streak_bonus = await self.config.guild(ctx.guild).streak_bonus()
+ current_bonus = min(user_stats["streak"], await self.config.guild(ctx.guild).streak_max()) * streak_bonus
+
+ embed = discord.Embed(
+ title="๐ฒ Drop Statistics",
+ description=f"Stats for {user.mention}",
+ color=discord.Color.blue()
+ )
+
+ stats_text = (
+ f"**Total Drops:** {total:,}\n"
+ f"**Successful:** โ
{user_stats['good']:,}\n"
+ f"**Bad Luck:** โ {user_stats.get('bad', 0):,}\n"
+ f"**Success Rate:** {success_rate:.1f}%\n\n"
+ f"**Current Streak:** ๐ฅ {user_stats['streak']}\n"
+ f"**Highest Streak:** โญ {user_stats.get('highest_streak', 0)}\n"
+ f"**Current Bonus:** +{current_bonus}% credits"
+ )
+
+ embed.add_field(name="๐ Statistics", value=stats_text, inline=False)
+ await ctx.send(embed=embed)
+
+ @lootdrop.command(name="leaderboard", aliases=["lb"])
+ async def lootdrop_leaderboard(self, ctx: commands.Context) -> None:
+ """View the loot drop leaderboard (Top 5)"""
+ _, leaderboard = await self.get_leaderboard_position(ctx.guild, str(ctx.author.id))
+
+ if not leaderboard:
+ await ctx.send("No drops have been claimed yet!")
+ return
+
+ embed = discord.Embed(
+ title="๐ LootDrop Leaderboard",
+ color=discord.Color.gold()
+ )
+
+ # Get top 5 users
+ description = []
+ for i, (user_id, total, good, highest_streak) in enumerate(leaderboard[:5], 1):
+ user = ctx.guild.get_member(int(user_id))
+ if not user:
+ continue
+
+ medal = {1: "๐ฅ", 2: "๐ฅ", 3: "๐ฅ"}.get(i, "๐")
+ success_rate = (good / total) * 100 if total > 0 else 0
+
+ description.append(
+ f"{medal} **#{i}** {user.mention}\n"
+ f"โ {total:,} drops (โ
{good:,} | {success_rate:.1f}% success) | โญ Highest Streak: {highest_streak}"
+ )
+
+ if description:
+ embed.description = "\n\n".join(description)
+ else:
+ embed.description = "No valid leaderboard entries found"
+
+ # Add author's position if not in top 5
+ author_pos, _ = await self.get_leaderboard_position(ctx.guild, str(ctx.author.id))
+ if author_pos > 5:
+ stats = await self.config.guild(ctx.guild).user_stats()
+ author_stats = stats.get(str(ctx.author.id), {"good": 0, "bad": 0, "highest_streak": 0})
+ total = author_stats["good"] + author_stats.get("bad", 0)
+ success_rate = (author_stats["good"] / total) * 100 if total > 0 else 0
+
+ embed.add_field(
+ name="Your Position",
+ value=(
+ f"#{author_pos} with {total:,} drops "
+ f"(โ
{author_stats['good']:,} | {success_rate:.1f}% success) | "
+ f"โญ Highest Streak: {author_stats.get('highest_streak', 0)}"
+ ),
+ inline=False
+ )
+
+ await ctx.send(embed=embed)
+
+ @lootdrop.command(name="settings")
+ async def lootdrop_settings(self, ctx: commands.Context) -> None:
+ """View current LootDrop settings"""
+ settings: Dict[str, Any] = await self.config.guild(ctx.guild).all()
+ currency_name = await bank.get_currency_name(ctx.guild)
+
+ channels: List[str] = []
+ for channel_id in settings["channels"]:
+ # Try to get as text channel first
+ channel = ctx.guild.get_channel(channel_id)
+ # If not found, try to get as thread
+ if not channel:
+ for thread in ctx.guild.threads:
+ if thread.id == channel_id:
+ channel = thread
+ break
+
+ if channel:
+ channels.append(channel.mention)
+
+ embed: discord.Embed = discord.Embed(
+ title="๐ฐ LootDrop Settings",
+ description=f"**Status:** {'๐ข Enabled' if settings['enabled'] else '๐ด Disabled'}",
+ color=discord.Color.gold() if settings['enabled'] else discord.Color.greyple()
+ )
+
+ # Channel Settings
+ if channels:
+ embed.add_field(
+ name="๐ Channels",
+ value=humanize_list(channels),
+ inline=False
+ )
+ else:
+ embed.add_field(
+ name="๐ Channels",
+ value="*No channels configured*\nUse `lootdrop set addchannel` to add channels or threads",
+ inline=False
+ )
+
+ # Drop Settings
+ drop_settings = (
+ f"**{currency_name}:** {settings['min_credits']:,} - {settings['max_credits']:,}\n"
+ f"**Bad Outcome:** {settings['bad_outcome_chance']}% chance\n"
+ f"**Claim Time:** {settings['drop_timeout']} seconds\n"
+ f"**Frequency:** {settings['min_frequency'] // 60} - {settings['max_frequency'] // 60} minutes"
+ )
+ embed.add_field(name="โ๏ธ Drop Settings", value=drop_settings, inline=False)
+
+ # Activity Settings
+ activity_settings = (
+ f"**Timeout:** {settings['activity_timeout'] // 60} minutes of inactivity\n"
+ "*Channels must have recent messages to receive drops*"
+ )
+ embed.add_field(name="โฐ Activity Settings", value=activity_settings, inline=False)
+
+ # Streak Settings
+ streak_settings = (
+ f"**Bonus:** {settings['streak_bonus']}% per level\n"
+ f"**Max Streak:** {settings['streak_max']}x\n"
+ f"**Timeout:** {settings['streak_timeout']} hours"
+ )
+ embed.add_field(name="๐ฅ Streak Settings", value=streak_settings, inline=False)
+
+ # Party Drop Settings
+ party_settings = (
+ f"**Chance:** {settings['party_drop_chance']}% chance\n"
+ f"**{currency_name}:** {settings['party_drop_min']:,} - {settings['party_drop_max']:,}\n"
+ f"**Timeout:** {settings['party_drop_timeout']} seconds"
+ )
+ embed.add_field(name="๐ Party Drop Settings", value=party_settings, inline=False)
+
+ # Next Drop Info
+ if settings["enabled"]:
+ now: int = int(datetime.datetime.now().timestamp())
+ next_drop: int = settings["next_drop"]
+
+ if next_drop <= now:
+ next_drop_text = "**๐ Drop is due now!**"
+ else:
+ minutes_left: int = (next_drop - now) // 60
+ seconds_left: int = (next_drop - now) % 60
+
+ if minutes_left > 0:
+ next_drop_text = f"**Next drop in {minutes_left}m {seconds_left}s**"
+ else:
+ next_drop_text = f"**Next drop in {seconds_left}s**"
+
+ embed.add_field(name="โณ Next Drop", value=next_drop_text, inline=False)
+
+ # Footer with command hint
+ embed.set_footer(text="Use 'help lootdrop' to see all commands")
+
+ await ctx.send(embed=embed)
+
+ async def get_leaderboard_position(self, guild: discord.Guild, user_id: str) -> Tuple[int, List[Tuple[str, int, int, int]]]:
+ """Get user's position and full leaderboard data"""
+ stats = await self.config.guild(guild).user_stats()
+
+ # Convert stats to list of (user_id, total_claims, good_claims, highest_streak)
+ leaderboard = [
+ (uid, data["good"] + data.get("bad", 0), data["good"], data.get("highest_streak", 0))
+ for uid, data in stats.items()
+ if data["good"] + data.get("bad", 0) > 0 # Only include users with at least one drop
+ ]
+
+ # Sort by total claims (desc) and then highest streak (desc)
+ leaderboard.sort(key=lambda x: (x[1], x[3]), reverse=True)
+
+ # Find user's position (1-based index)
+ # If user has drops but somehow not in leaderboard, give last place instead of 0
+ user_stats = stats.get(user_id, {"good": 0, "bad": 0})
+ total_drops = user_stats["good"] + user_stats.get("bad", 0)
+
+ if total_drops == 0:
+ position = 0
+ else:
+ position = next((i + 1 for i, (uid, _, _, _) in enumerate(leaderboard) if uid == user_id), len(leaderboard) + 1)
+
+ return position, leaderboard
+
+
+class DropButton(discord.ui.Button['LootDropView']):
+ """Button for claiming regular loot drops
+
+ This button is used for single-claim drops where only one user can claim the reward.
+ The button is removed after a successful claim.
+
+ Attributes
+ ----------
+ scenario: Scenario
+ The scenario this button is for
+ """
+ def __init__(self, scenario: Scenario) -> None:
+ super().__init__(
+ style=discord.ButtonStyle.success,
+ emoji=scenario["button_emoji"],
+ label=scenario["button_text"]
+ )
+ self.scenario = scenario
+
+ async def callback(self, interaction: discord.Interaction) -> None:
+ """Handle button press
+
+ Parameters
+ ----------
+ interaction: discord.Interaction
+ The interaction that triggered this callback
+ """
+ if interaction.user.bot:
+ return
+
+ assert self.view is not None
+
+ if self.view.claimed:
+ await interaction.response.send_message("This drop has already been claimed!", ephemeral=True)
+ return
+
+ self.view.claimed = True
+ await interaction.response.defer()
+
+ # Remove from active drops and cancel timeout task
+ if interaction.guild_id in self.view.cog.active_drops:
+ del self.view.cog.active_drops[interaction.guild_id]
+ if interaction.guild_id in self.view.cog.tasks:
+ self.view.cog.tasks[interaction.guild_id].cancel()
+ del self.view.cog.tasks[interaction.guild_id]
+
+ await self.view.cog.process_loot_claim(interaction)
+ try:
+ await interaction.message.edit(view=None)
+ except:
+ pass
+
+
+class LootDropView(discord.ui.View):
+ """View for regular loot drops
+
+ This view contains a single button that can be claimed once.
+ After claiming, the button is removed and rewards are distributed.
+
+ Attributes
+ ----------
+ cog: LootDrop
+ The cog instance that created this view
+ message: Optional[discord.Message]
+ The message containing this view
+ claimed: bool
+ Whether this drop has been claimed
+ scenario: Scenario
+ The scenario for this drop
+ """
+ def __init__(self, cog: 'LootDrop', scenario: Scenario, timeout: float) -> None:
+ super().__init__(timeout=timeout)
+ self.cog: 'LootDrop' = cog
+ self.message: Optional[discord.Message] = None
+ self.claimed: bool = False
+ self.scenario: Scenario = scenario
+ self.add_item(DropButton(scenario))
+
+ async def on_timeout(self) -> None:
+ """Handle view timeout
+
+ Removes the button and updates the message when the drop expires
+ """
+ if self.message and not self.claimed:
+ try:
+ await self.message.edit(content="The opportunity has passed...", view=None)
+ except:
+ pass
+
+
+class PartyDropView(discord.ui.View):
+ """View for party drops that multiple users can claim
+
+ This view allows multiple users to claim rewards within a time window.
+ The button remains active until timeout, allowing multiple users to join.
+
+ Attributes
+ ----------
+ cog: LootDrop
+ The cog instance that created this view
+ claimed_users: Dict[str, float]
+ Dict of user IDs to their claim timestamps
+ message: Optional[discord.Message]
+ The message containing this view
+ start_time: float
+ When the party drop was created
+ """
+ def __init__(self, cog: 'LootDrop', timeout: float) -> None:
+ super().__init__(timeout=timeout)
+ self.cog: 'LootDrop' = cog
+ self.claimed_users: Dict[str, float] = {}
+ self.message: Optional[discord.Message] = None
+ self.start_time: float = time.time()
+
+ @discord.ui.button(label="Claim Party Drop!", emoji="๐", style=discord.ButtonStyle.success)
+ async def claim_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
+ """Handle party drop claims
+
+ Parameters
+ ----------
+ interaction: discord.Interaction
+ The interaction that triggered this callback
+ button: discord.ui.Button
+ The button that was clicked
+ """
+ if interaction.user.bot:
+ return
+
+ if str(interaction.user.id) in self.claimed_users:
+ await interaction.response.send_message("You've already claimed this party drop!", ephemeral=True)
+ return
+
+ # Add user to claimed list with timestamp
+ self.claimed_users[str(interaction.user.id)] = time.time()
+
+ # Update button label to show number of participants
+ button.label = f"Join Party! ({len(self.claimed_users)} joined)"
+
+ # Acknowledge the interaction and update button
+ await interaction.response.edit_message(view=self)
+
+ # Send confirmation
+ await interaction.followup.send("๐ You've joined the party! Wait for rewards...", ephemeral=True)
+
+ async def on_timeout(self) -> None:
+ """Handle view timeout
+
+ Called when the party drop timer expires. The button will be removed
+ and rewards will be processed by the cog's timeout handler.
+ """
+ if self.message:
+ try:
+ await self.message.edit(view=None)
+ except:
+ pass
diff --git a/lootdrop/scenarios.py b/lootdrop/scenarios.py
new file mode 100644
index 0000000..38aeb09
--- /dev/null
+++ b/lootdrop/scenarios.py
@@ -0,0 +1,404 @@
+"""
+Scenarios for the LootDrop cog
+Each scenario has:
+- start: The initial scenario message
+- good: Good outcome message template
+- bad: Bad outcome message template
+- button_text: Text to show on the claim button
+- button_emoji: Emoji to show on the claim button
+
+The {user} placeholder will be replaced with the user's mention in outcomes
+The {amount} placeholder will be replaced with the currency amount
+The {currency} placeholder will be replaced with the guild's currency name
+"""
+from typing import List, Dict, TypedDict
+
+
+class Scenario(TypedDict):
+ start: str # The initial scenario message
+ good: str # Good outcome message template
+ bad: str # Bad outcome message template
+ button_text: str # Text to show on claim button
+ button_emoji: str # Emoji to show on claim button
+
+
+SCENARIOS: List[Scenario] = [
+ {
+ "start": "๐พ A dusty USB drive labeled 'Top Secret Mod Notes' lies forgotten...",
+ "good": "{user} returns the drive to the mods and gets {amount} {currency} for their honesty!",
+ "bad": "The drive contained a virus! {user} pays {amount} {currency} to the server tech support role.",
+ "button_text": "Check Drive",
+ "button_emoji": "๐พ"
+ },
+ {
+ "start": "๐ค A wild `LootBot` appears in the channel, sparkling strangely...",
+ "good": "{user} interacts with the bot and it glitches, dropping {amount} {currency}!",
+ "bad": "The bot was a mimic! It bites {user} and steals {amount} {currency}!",
+ "button_text": "Interact",
+ "button_emoji": "๐ค"
+ },
+ {
+ "start": "๐ฅ A DM pops up from a new user: 'Click here for free {currency}!!1!'...",
+ "good": "{user} reports the scammer and the server owner rewards them with {amount} {currency}!",
+ "bad": "It was a phishing link! {user} loses {amount} {currency} fixing their account!",
+ "button_text": "Check Link",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "๐ A Discord Nitro link appears in the channel...",
+ "good": "{user} claims the Nitro and finds a bonus {amount} {currency}!",
+ "bad": "It was a fake link! {user} feels the sting of disappointment in themselves and was hacked, losing {amount} {currency}.",
+ "button_text": "Claim Nitro",
+ "button_emoji": "โจ"
+ },
+ {
+ "start": "๐ค Someone started Karaoke night in the VC, but the next singer is shy...",
+ "good": "{user} belts out a banger! The crowd showers them with {amount} {currency}!",
+ "bad": "{user}'s mic feedback breaks the bot! They pay {amount} {currency} for repairs!",
+ "button_text": "Grab Mic",
+ "button_emoji": "๐ค"
+ },
+ {
+ "start": "๐ฐ A suspicious looking wallet lies on the ground...",
+ "good": "{user} steals the wallet full of {amount} {currency} and runs away!",
+ "bad": "{user} was caught by the police! {amount} {currency} fine!",
+ "button_text": "Pick up wallet",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "โจ A mysterious chest materializes out of thin air...",
+ "good": "{user} opens the chest and finds {amount} {currency} worth of treasure!",
+ "bad": "The chest was cursed! {user} pays {amount} {currency} to break free!",
+ "button_text": "Open chest",
+ "button_emoji": "๐๏ธ"
+ },
+ {
+ "start": "๐ฒ A sketchy merchant appears with a game of chance...",
+ "good": "{user} wins the game and receives {amount} {currency}!",
+ "bad": "{user} loses the game and pays {amount} {currency} to the merchant!",
+ "button_text": "Play game",
+ "button_emoji": "๐ฒ"
+ },
+ {
+ "start": "๐ญ A street performer seeks a volunteer from the crowd...",
+ "good": "The crowd loves {user}'s performance! They earn {amount} {currency} in tips!",
+ "bad": "{user} accidentally breaks the props! They pay {amount} {currency} in damages!",
+ "button_text": "Volunteer",
+ "button_emoji": "๐ญ"
+ },
+ {
+ "start": "๐ฆ An unmarked package sits mysteriously on the doorstep...",
+ "good": "{user} opens a surprise gift containing {amount} {currency}!",
+ "bad": "It's a prank package! {user} spends {amount} {currency} cleaning up the mess!",
+ "button_text": "Open package",
+ "button_emoji": "๐ฆ"
+ },
+ {
+ "start": "๐ช An enticing carnival game stand appears...",
+ "good": "{user} wins the jackpot! {amount} {currency} richer!",
+ "bad": "The game was rigged by a rival server! {user} loses {amount} {currency}!",
+ "button_text": "Try your luck",
+ "button_emoji": "๐ฏ"
+ },
+ {
+ "start": "๐ฎ A vintage arcade cabinet flickers to life...",
+ "good": "{user} finds {amount} {currency} worth of tokens inside!",
+ "bad": "The machine eats {user}'s {currency}! {amount} lost to the void!",
+ "button_text": "Insert coin",
+ "button_emoji": "๐น๏ธ"
+ },
+ {
+ "start": "๐ฃ A golden fishing rod floats in a nearby pond...",
+ "good": "{user} catches a rare fish worth {amount} {currency}!",
+ "bad": "{user} falls in and loses {amount} {currency} worth of electronics!",
+ "button_text": "Cast line",
+ "button_emoji": "๐ฃ"
+ },
+ {
+ "start": "๐จ A street artist offers to paint your portrait...",
+ "good": "The painting sells for {amount} {currency}! {user} gets the profits!",
+ "bad": "The paint was permanent! {user} pays {amount} {currency} for removal!",
+ "button_text": "Pose",
+ "button_emoji": "๐จ"
+ },
+ {
+ "start": "๐ต A ghostly music box plays a haunting melody...",
+ "good": "The ghost rewards {user} with {amount} {currency} for listening!",
+ "bad": "The cursed tune costs {user} {amount} {currency} to forget!",
+ "button_text": "Listen closer",
+ "button_emoji": "๐ป"
+ },
+ {
+ "start": "๐ A dormant volcano rumbles ominously...",
+ "good": "{user} discovers ancient treasure worth {amount} {currency}!",
+ "bad": "The eruption destroys {user}'s belongings! {amount} {currency} in damages!",
+ "button_text": "Investigate",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "๐ช A time traveler's briefcase appears in a flash of light...",
+ "good": "{user} finds futuristic currency worth {amount} {currency}!",
+ "bad": "Temporal police fine {user} {amount} {currency} for interference!",
+ "button_text": "Open briefcase",
+ "button_emoji": "โ"
+ },
+ {
+ "start": "๐ฐ A malfunctioning vending machine sparks and whirs...",
+ "good": "The machine dispenses {amount} {currency} to {user}!",
+ "bad": "The machine swallows {user}'s card and charges {amount} {currency}!",
+ "button_text": "Press buttons",
+ "button_emoji": "๐ฐ"
+ },
+ {
+ "start": "๐ญ An ancient mask whispers secrets of power...",
+ "good": "{user} learns wisdom worth {amount} {currency}!",
+ "bad": "The mask possesses {user}! Exorcism costs {amount} {currency}!",
+ "button_text": "Wear mask",
+ "button_emoji": "๐บ"
+ },
+ {
+ "start": "๐ช A dimensional rift tears open reality...",
+ "good": "Alternate {user} sends {amount} {currency} through the portal!",
+ "bad": "Evil {user} steals {amount} {currency} and escapes!",
+ "button_text": "Enter portal",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "๐จ A blank canvas radiates mysterious energy...",
+ "good": "{user}'s artwork magically comes alive, worth {amount} {currency}!",
+ "bad": "The painting traps {user}! Rescue costs {amount} {currency}!",
+ "button_text": "Start painting",
+ "button_emoji": "๐๏ธ"
+ },
+ {
+ "start": "๐ญ A magical mirror shows your reflection...",
+ "good": "{user}'s reflection hands over {amount} {currency}!",
+ "bad": "The mirror traps {user}'s shadow! Ransom costs {amount} {currency}!",
+ "button_text": "Touch mirror",
+ "button_emoji": "๐ญ"
+ },
+ {
+ "start": "๐ช A cosmic vending machine descends from space...",
+ "good": "{user} receives alien technology worth {amount} {currency}!",
+ "bad": "The machine abducts {user}'s {currency}! {amount} lost to space!",
+ "button_text": "Insert {currency}",
+ "button_emoji": "๐ฝ"
+ },
+ {
+ "start": "๐ฒ A mysterious game board draws you in...",
+ "good": "{user} wins the cosmic game and {amount} {currency}!",
+ "bad": "Jumanji-style chaos costs {user} {amount} {currency} to fix!",
+ "button_text": "Roll dice",
+ "button_emoji": "๐ฒ"
+ },
+ {
+ "start": "๐ A unicorn offers to grant a wish...",
+ "good": "{user}'s pure heart earns {amount} {currency}!",
+ "bad": "The unicorn judges {user} unworthy! Fine of {amount} {currency}!",
+ "button_text": "Make wish",
+ "button_emoji": "๐ฆ"
+ },
+ {
+ "start": "๐ญ A sentient shadow offers a deal...",
+ "good": "{user} outsmarts the shadow and gains {amount} {currency}!",
+ "bad": "The shadow steals {user}'s luck! {amount} {currency} to recover!",
+ "button_text": "Make deal",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "๐ฆ An ice cream truck is playing its tune late at night...",
+ "good": "{user} gets free unlimited ice cream and {amount} {currency}!",
+ "bad": "It was a trap! {user} pays {amount} {currency} for melted ice cream!",
+ "button_text": "Chase truck",
+ "button_emoji": "๐ฆ"
+ },
+ {
+ "start": "๐ฐ A casino slot machine is spinning on its own...",
+ "good": "{user} hits the jackpot! {amount} {currency} richer!",
+ "bad": "Security catches {user} and fines them {amount} {currency}!",
+ "button_text": "Pull lever",
+ "button_emoji": "๐ฐ"
+ },
+ {
+ "start": "๐ The jewelry store's alarm system appears to be malfunctioning...",
+ "good": "{user} finds a lost diamond worth {amount} {currency}!",
+ "bad": "The alarm was fake! {user} pays {amount} {currency} in fines!",
+ "button_text": "Investigate",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "๐ฆ The bank's vault door has opened by itself...",
+ "good": "{user} finds unclaimed {currency} worth {amount}!",
+ "bad": "It was a security test! {user} pays {amount} {currency} in fines!",
+ "button_text": "Peek inside",
+ "button_emoji": "๐ฆ"
+ },
+ {
+ "start": "๐ A luxury car is parked nearby with keys in the ignition...",
+ "good": "{user} returns the car and gets {amount} {currency} reward!",
+ "bad": "The owner catches {user}! {amount} {currency} fine!",
+ "button_text": "Check car",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "๐ญ A masquerade party is happening at the mansion...",
+ "good": "{user} wins best costume and {amount} {currency}!",
+ "bad": "The mask falls off! {user} pays {amount} {currency} to escape!",
+ "button_text": "Crash party",
+ "button_emoji": "๐ญ"
+ },
+ {
+ "start": "๐ผ๏ธ The art gallery's security cameras have stopped working...",
+ "good": "{user} discovers a forgotten masterpiece worth {amount} {currency}!",
+ "bad": "The art was fake! {user} loses {amount} {currency}!",
+ "button_text": "Browse art",
+ "button_emoji": "๐ผ๏ธ"
+ },
+ {
+ "start": "๐ช Music is coming from the circus tent after hours...",
+ "good": "{user} finds {amount} {currency} in lost tickets!",
+ "bad": "The clowns catch {user}! {amount} {currency} to join the show!",
+ "button_text": "Sneak in",
+ "button_emoji": "๐ช"
+ },
+ {
+ "start": "๐ฐ The castle's treasure room door is standing wide open...",
+ "good": "{user} finds ancient coins worth {amount} {currency}!",
+ "bad": "The knights catch {user}! {amount} {currency} fine!",
+ "button_text": "Enter room",
+ "button_emoji": "๐ฐ"
+ },
+ {
+ "start": "๐ต A street musician has left their hat full of coins...",
+ "good": "{user} performs and earns {amount} {currency} in tips!",
+ "bad": "The crowd boos! {user} pays {amount} {currency} to leave!",
+ "button_text": "Join music",
+ "button_emoji": "๐ต"
+ },
+ {
+ "start": "๐ฎ The arcade's machines are running without power...",
+ "good": "{user} rescues {amount} {currency} worth of tokens!",
+ "bad": "The games malfunction! {user} pays {amount} {currency} in damages!",
+ "button_text": "Check games",
+ "button_emoji": "๐ฎ"
+ },
+ {
+ "start": "๐ A helicopter is sitting idle with keys in the cockpit...",
+ "good": "{user} takes a joyride and finds {amount} {currency}!",
+ "bad": "Crash landing! {user} pays {amount} {currency} in repairs!",
+ "button_text": "Start heli",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "๐จ The museum is unveiling a mysterious new exhibit...",
+ "good": "{user} discovers a lost artwork worth {amount} {currency}!",
+ "bad": "The curator catches {user}! Fine of {amount} {currency}!",
+ "button_text": "Preview art",
+ "button_emoji": "๐จ"
+ },
+ {
+ "start": "๐ฉ A magician's hat is sitting unattended on stage...",
+ "good": "{user} pulls out {amount} {currency} worth of magic!",
+ "bad": "The rabbit bites! {user} pays {amount} {currency} in damages!",
+ "button_text": "Reach in",
+ "button_emoji": "๐ฉ"
+ },
+ {
+ "start": "๐ด A private beach at the resort is completely empty...",
+ "good": "{user} finds buried treasure worth {amount} {currency}!",
+ "bad": "Security escorts {user} out! {amount} {currency} fine!",
+ "button_text": "Explore beach",
+ "button_emoji": "๐ด"
+ },
+ {
+ "start": "๐ฃ Someone is hosting a fishing event...",
+ "good": "{user} catches the legendary server fish, worth {amount} {currency}!",
+ "bad": "{user} snags their line on the bot's code, costing {amount} {currency} to untangle!",
+ "button_text": "Cast Line",
+ "button_emoji": "๐ฃ"
+ },
+ {
+ "start": "๐ป A ghostly ping notification sound echoes, but there's no new message...",
+ "good": "It's a ghost notification! {user} finds the phantom {amount} {currency} left behind!",
+ "bad": "The sound haunts {user}! They pay {amount} {currency} for premium sound packs to forget it!",
+ "button_text": "Investigate Ping",
+ "button_emoji": "๐ป"
+ },
+ {
+ "start": "๐ A new quest pops up on the server notice board! 'Defeat 10 Spam Bots'...",
+ "good": "{user} completes the quest and earns {amount} {currency} from the Quest Master role!",
+ "bad": "The spam bots overwhelmed {user}! They pay {amount} {currency} for anti-spam protection.",
+ "button_text": "Accept Quest",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "๐ฒ A wild Server Boss (a glitchy bot?) appears, dropping loot!",
+ "good": "{user} lands the killing blow and gets the Legendary Loot Drop worth {amount} {currency}!",
+ "bad": "The Boss's AOE attack hits {user}! Repair costs are {amount} {currency}!",
+ "button_text": "Attack Boss",
+ "button_emoji": "โ๏ธ"
+ },
+ {
+ "start": "๐บ๏ธ An unexplored, dusty channel is discovered...",
+ "good": "{user} finds ancient server lore worth {amount} {currency} to the historians!",
+ "bad": "The channel is haunted by ghost pings! {user} pays {amount} {currency} for mental recovery.",
+ "button_text": "Explore Channel",
+ "button_emoji": "๐บ๏ธ"
+ },
+ {
+ "start": "๐ก๏ธ The server is being DDoSed!",
+ "good": "{user}'s quick thinking helps repel the attack! Rewarded with {amount} {currency} for valor!",
+ "bad": "{user}'s connection drops during the fight! Reconnecting costs {amount} {currency}.",
+ "button_text": "Defend Server",
+ "button_emoji": "๐ก๏ธ"
+ },
+ {
+ "start": "โ๏ธ A small, whirring mechanical creature scurries by, trailing sparks...",
+ "good": "{user} catches the creature and finds it carries {amount} {currency}!",
+ "bad": "The creature unleashes an electric shock! {user} pays {amount} {currency} for repairs.",
+ "button_text": "Catch It",
+ "button_emoji": "โ๏ธ"
+ },
+ {
+ "start": "๐ A tattered scroll lies on the path, sealed with an unknown sigil...",
+ "good": "{user} breaks the seal and finds a treasure map leading to {amount} {currency}!",
+ "bad": "The scroll releases a minor curse! {user} pays {amount} {currency} to a local healer.",
+ "button_text": "Read Scroll",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "โ๏ธ A fragment of a falling star lands nearby, glowing softly...",
+ "good": "{user} carefully picks up the star fragment, finding it's worth {amount} {currency}!",
+ "bad": "The fragment burns {user}'s hand! Ointment costs {amount} {currency}.",
+ "button_text": "Touch Fragment",
+ "button_emoji": "โ๏ธ"
+ },
+ {
+ "start": "๐ป A faint, chilling whisper seems to echo from the shadows...",
+ "good": "{user} follows the whisper and finds {amount} {currency} hidden by a restless spirit!",
+ "bad": "The whisper drains {user}'s energy! A potion costs {amount} {currency}.",
+ "button_text": "Follow Whisper",
+ "button_emoji": "๐ป"
+ },
+ {
+ "start": "๐ A bounty is posted... 'Clear out the mischievous imps plaguing the area'",
+ "good": "{user} bravely defeats the imps and collects the {amount} {currency} reward!",
+ "bad": "The imps played tricks on {user}, stealing {amount} {currency}!",
+ "button_text": "Accept Bounty",
+ "button_emoji": "๐"
+ },
+ {
+ "start": "๐ฟ A hulking golem, crafted from stone and metal, blocks the path!",
+ "good": "{user} finds the golem's weak spot and disables it, finding {amount} {currency} inside!",
+ "bad": "The golem smashes {user}'s backpack! Replacing gear costs {amount} {currency}.",
+ "button_text": "Fight Golem",
+ "button_emoji": "๐ฟ"
+ },
+ {
+ "start": "โ๏ธ A sudden, unnatural storm gathers overhead...",
+ "good": "{user} finds shelter and discovers {amount} {currency} left by another traveler!",
+ "bad": "A lightning strike nearby scares {user}, causing them to drop {amount} {currency}!",
+ "button_text": "Seek Shelter",
+ "button_emoji": "โ๏ธ"
+ }
+]