diff --git a/cogs/admin.py b/cogs/admin.py new file mode 100644 index 0000000..fcbfe71 --- /dev/null +++ b/cogs/admin.py @@ -0,0 +1,371 @@ +import discord +from discord.ext import commands +import functions as func +import iufi +from iufi import CardPool +from views import ConfirmView + +import os +import datetime +import asyncio + +def is_admin_account(user_id) -> bool: + if user_id in func.settings.ADMIN_IDS: + return True + return False + + +class Admin(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.emoji = "🔧" + self.invisible = True + + @commands.command() + async def givecandies(self, ctx: commands.Context, user: discord.Member, amount: int): + """Give candies to a user.""" + if not is_admin_account(ctx.author.id): + return + + user_data = await func.get_user(user.id) + if not user_data: + return await ctx.reply("User not found.") + await func.update_user(user.id, {"$inc": {"candies": amount}}) + await ctx.reply(f"{amount} candies have been given to {user.display_name}.") + + @commands.command() + async def removecandies(self, ctx: commands.Context, user: discord.Member, amount: int): + """Remove candies from a user.""" + if not is_admin_account(ctx.author.id): + return + + user_data = await func.get_user(user.id) + if not user_data: + return await ctx.reply("User not found.") + await func.update_user(user.id, {"$inc": {"candies": -amount}}) + await ctx.reply(f"{amount} candies have been removed from {user.display_name}.") + + @commands.command() + async def resetCooldown(self, ctx: commands.Context, user: discord.Member, cooldown: str): + """Reset cooldown of a user. Cooldowns: roll, quiz, mg""" + if not is_admin_account(ctx.author.id): + return + cooldowns = {"roll": "roll", "quiz": "quiz_game", "mg": "match_game"} + + if cooldown not in cooldowns: + return await ctx.reply("Cooldown not found.") + + cooldown = cooldowns[cooldown] + + user_data = await func.get_user(user.id) + if not user_data: + return await ctx.reply("User not found.") + + await func.update_user(user.id, {"$set": {f"cooldown.{cooldown}": 0}}) + await ctx.reply(f"{cooldown} cooldown has been reset for {user.display_name}.") + + @commands.command(aliases=["resetcontract", "reset_contract"]) + async def resetEventContract(self, ctx: commands.Context, user: discord.Member): + """Clear a user's Royal Contract team so they can pick again (`contract`).""" + if not is_admin_account(ctx.author.id): + return + + user_data = await func.get_user(user.id) + if not user_data: + return await ctx.reply("User not found.") + + if not user_data.get("event_team"): + return await ctx.reply(f"{user.display_name} has no active Royal Contract to reset.") + + await func.update_user(user.id, {"$unset": {"event_team": ""}}) + await ctx.reply( + f"Royal Contract cleared for {user.mention}. They can register again with `contract`." + ) + + @commands.command() + async def resetCardTradeCooldown(self, ctx: commands.Context, card_id: str): + """Remove cooldown of a card.""" + if not is_admin_account(ctx.author.id): + return + + card = iufi.CardPool.get_card(card_id) + if not card: + return await ctx.reply("Card not found.") + + await func.update_card(card_id, {"$set": {"last_trade_time": 0}}) + await ctx.reply(f"Cooldown has been reset for card {card_id}.") + + @commands.command() + async def giveCardToUser(self, ctx: commands.Context, user: discord.Member, card_id: str): + """Give a card to a user.""" + if not is_admin_account(ctx.author.id): + return + + card = iufi.CardPool.get_card(card_id) + if not card: + return await ctx.reply("Card not found.") + + if card.owner_id: + return await ctx.reply("Card already owned by someone.") + + user_data = await func.get_user(user.id) + + if not user_data: + return await ctx.reply("User not found.") + + if len(user_data["cards"]) >= func.settings.MAX_CARDS: + return await ctx.reply(f"{user.display_name} already has maximum cards.") + + card.change_owner(user.id) + CardPool.remove_available_card(card) + await func.update_card(card_id, {"$set": {"owner_id": user.id}}) + await func.update_user(user.id, {"$push": {"cards": card_id}}) + + await ctx.reply(f"Card {card_id} has been given to {user.display_name}.") + + @commands.command() + async def removeCardFromUser(self, ctx: commands.Context, card_id: str): + """Remove a card from a user.""" + if not is_admin_account(ctx.author.id): + return + + card = iufi.CardPool.get_card(card_id) + if not card: + return await ctx.reply("Card not found.") + + if not card.owner_id: + return await ctx.reply("Card is not owned by anyone.") + + card.change_owner(None) + CardPool.add_available_card(card) + await func.update_card(card_id, {"$set": {"owner_id": None, "tag": None, "frame": None, "last_trade_time": 0}}) + await func.update_user(card.owner_id, {"$pull": {"cards": card.id}}) + + await ctx.reply(f"Card {card_id} has been removed from user.") + + @commands.command() + async def giveRollToUser(self, ctx: commands.Context, user: discord.Member, roll_type: str, amount: int = 1): + """Give rolls to a user.""" + if not is_admin_account(ctx.author.id): + return + + roll_types = ["rare", "epic", "legendary", "mystic", "celestial"] + + if roll_type not in roll_types: + return await ctx.reply("Roll type not found.") + + user_data = await func.get_user(user.id) + if not user_data: + return await ctx.reply("User not found.") + + await func.update_user(user.id, {"$inc": {f"roll.{roll_type}": amount}}) + await ctx.reply(f"{amount} {roll_type} rolls have been given to {user.display_name}.") + + @commands.command() + async def giveBirthdayCard(self, ctx: commands.Context, user: discord.Member, day_number: int): + """Give a birthday card to a user.""" + if not is_admin_account(ctx.author.id): + return + + if day_number < 1 or day_number > 31: + return await ctx.reply("Invalid day number. Must be between 1 and 31.") + + user_data = await func.get_user(user.id) + if not user_data: + return await ctx.reply("User not found.") + + # Convert day number to string for storage in the collection + day_str = str(day_number) + + # Check if user already has this card + birthday_collection = user_data.get("birthday_collection", {}) + if day_str in birthday_collection: + return await ctx.reply(f"{user.display_name} already has birthday card #{day_number}.") + + # Add card to user's collection + update_query = { + "$set": {f"birthday_collection.{day_str}": True}, + "$inc": {"birthday_cards_count": 1, "exp": 20} + } + + await func.update_user(user.id, update_query) + await ctx.reply(f"Birthday card #{day_number} has been given to {user.display_name}.") + + @commands.command() + async def setBirthdayCardsCount(self, ctx: commands.Context, user: discord.Member, count: int): + """Set the birthday cards count for a user.""" + if not is_admin_account(ctx.author.id): + return + + user_data = await func.get_user(user.id) + if not user_data: + return await ctx.reply("User not found.") + + # Set the birthday cards count + await func.update_user(user.id, {"$set": {"birthday_cards_count": count}}) + await ctx.reply(f"Birthday cards count for {user.display_name} has been set to {count}.") + + @commands.command() + async def quit(self, ctx: commands.Context, member: discord.Member = None): + """[ADMIN ONLY] Deletes a user's profile after confirmation. All cards will be converted. + + If no member is specified, it will delete the profile of the user who called the command. + + **Examples:** + @prefix@quit @username + @prefix@quit + """ + if not is_admin_account(ctx.author.id): + return await ctx.reply("You don't have permission to use this command.") + + target_user = member or ctx.author + user = await func.get_user(target_user.id) + + # Create confirmation embed + embed = discord.Embed(title="⚠️ Delete Account", color=discord.Color.red()) + embed.description = f"**WARNING: This action cannot be undone!**\n\nThis will:\n- Conver all {target_user.display_name}'s cards \n- Delete their entire profile and progress\n- Remove all inventory items and collections\n\nAre you sure you want to continue?" + + # Create confirmation view + view = ConfirmView(ctx.author) + view.message = await ctx.reply(embed=embed, view=view) + await view.wait() + + if not view.is_confirm: + embed.title = "❌ Account Deletion Cancelled" + embed.description = f"{target_user.display_name}'s account has not been deleted." + embed.color = discord.Color.green() + await view.message.edit(embed=embed, view=None) + return + + # Convert all cards to candies (for logging purposes only) + converted_cards = [] + for card_id in user["cards"]: + card = iufi.CardPool.get_card(card_id) + if card: + converted_cards.append(card) + + card_ids = [card.id for card in converted_cards] + candies = sum([card.cost for card in converted_cards]) + + for card in converted_cards: + iufi.CardPool.add_available_card(card) + + # Log the action + func.logger.info( + f"Admin {ctx.author.name}({ctx.author.id}) deleted the profile of {target_user.name}({target_user.id}). " + f"Returned {len(converted_cards)} card(s) to the available pool." + ) + + # Update the cards in the database to remove owner, tag, etc. + if card_ids: + await func.update_card(card_ids, + {"$set": {"owner_id": None, "tag": None, "frame": None, "last_trade_time": 0}}) + + # Delete the user from the database + await func.USERS_DB.delete_one({"_id": target_user.id}) + + # Remove user from buffer cache if they exist there + if target_user.id in func.USERS_BUFFER: + del func.USERS_BUFFER[target_user.id] + + # Update the confirmation message + embed.title = "✅ Account Deleted" + embed.description = f"{target_user.display_name}'s Account has been deleted. All their cards ({len(converted_cards)}) have been returned to the available pool." + embed.color = discord.Color.green() + await view.message.edit(embed=embed, view=None) + + @commands.command() + async def fetch_messages(self, ctx: commands.Context, channel_id: int, fetch_reactions: bool = False): + """ + Fetches all messages sent in this year in a given channel ID and stores them locally in a text file. + + **Parameters:** + `channel_id`: The ID of the channel to fetch messages from. + `fetch_reactions`: Whether to also fetch and store information about reactions (emojis and who reacted). Default is False. + + **Examples:** + @prefix@fetch_messages 123456789012345678 + @prefix@fetch_messages 123456789012345678 True + """ + + if not is_admin_account(ctx.author.id): + return await ctx.reply("You don't have permission to use this command.") + + try: + channel = self.bot.get_channel(channel_id) + if not channel: + channel = await self.bot.fetch_channel(channel_id) + except: + return await ctx.reply("Channel not found or I don't have access to it.") + + if not isinstance(channel, (discord.TextChannel, discord.Thread, discord.VoiceChannel, discord.StageChannel)): + return await ctx.reply("The provided ID does not belong to a text-based channel.") + + current_year = datetime.datetime.now().year + start_of_year = datetime.datetime(current_year, 1, 1) + + status_text = f"Starting to fetch messages from {channel.mention} since {start_of_year.date()}" + if fetch_reactions: + status_text += " (including reactions)..." + else: + status_text += "..." + + status_msg = await ctx.reply(status_text) + + suffix = "_with_reactions" if fetch_reactions else "" + file_name = f"logs/messages_{channel.name.replace('/', '-')}_{current_year}{suffix}.txt" + + # Ensure directory exists + os.makedirs("logs", exist_ok=True) + + # Clear/Create the file + with open(file_name, "w", encoding="utf-8") as f: + pass + + log_buffer = [] + count = 0 + BATCH_SIZE = 1000 if fetch_reactions else 2500 + + async for message in channel.history(after=start_of_year, limit=None, oldest_first=True): + timestamp = message.created_at.strftime("%Y-%m-%d %H:%M:%S") + content = message.content.replace('\n', ' ') + line = f"[{timestamp}] ({message.id}) {message.author} ({message.author.id}): {content}" + + if fetch_reactions and message.reactions: + reactions_data = [] + for reaction in message.reactions: + users = [f"{u.name}#{u.discriminator}" if u.discriminator != "0" else u.name async for u in reaction.users()] + reactions_data.append(f"{reaction.emoji} (Users: {', '.join(users)})") + + if reactions_data: + line += f" | Reactions: {' | '.join(reactions_data)}" + + log_buffer.append(line + "\n") + + count += 1 + + if count % 100 == 0: + await asyncio.sleep(0.1) + + if count % 500 == 0: + await status_msg.edit(content=f"Fetched {count} messages{' (with reactions)' if fetch_reactions else ''} so far...") + + if len(log_buffer) >= BATCH_SIZE: + # Write batch to file + with open(file_name, "a", encoding="utf-8") as f: + f.writelines(log_buffer) + log_buffer.clear() + + # Write remaining + if log_buffer: + with open(file_name, "a", encoding="utf-8") as f: + f.writelines(log_buffer) + + if count == 0: + return await status_msg.edit(content="No messages found in this channel for the current year.") + + await ctx.send(f"Successfully fetched {count} messages and saved them to `{file_name}`.") + + +async def setup(bot: commands.Bot): + await bot.add_cog(Admin(bot)) diff --git a/cogs/card.py b/cogs/card.py index 842e990..e17c3ac 100644 --- a/cogs/card.py +++ b/cogs/card.py @@ -3,6 +3,7 @@ from discord import app_commands from discord.ext import commands +from iufi.perfect_crown import apply_contract_candy_reward from views import ( ConfirmView, @@ -107,6 +108,7 @@ async def convert(self, ctx: commands.Context, *, card_ids: str): converted_cards.append(card) user = await func.get_user(ctx.author.id) + candies = apply_contract_candy_reward(candies, user) query = func.update_quest_progress(user, "CONVERT_ANY_CARD", progress=len(converted_cards), query={ "$pull": {"cards": {"$in": (card_ids := [card.id for card in converted_cards])}}, "$inc": {"candies": candies} @@ -140,7 +142,8 @@ async def convertlast(self, ctx: commands.Context): return embed = discord.Embed(color=discord.Color.random()) - embed.description = f"```🆔 {card} \n🍬 + {card.cost}```" + reward_candies = apply_contract_candy_reward(card.cost, user) + embed.description = f"```🆔 {card} \n🍬 + {reward_candies}```" message: discord.Message = None if card.tier[1] not in ["common", "rare"] or card.tag: @@ -160,12 +163,12 @@ async def convertlast(self, ctx: commands.Context): query = func.update_quest_progress(user, "CONVERT_ANY_CARD", query={ "$pull": {"cards": card.id}, - "$inc": {"candies": card.cost} + "$inc": {"candies": reward_candies} }) await func.update_user(ctx.author.id, query) await func.update_card(card.id, {"$set": {"owner_id": None, "tag": None, "frame": None, "last_trade_time": 0}}) - func.logger.info(f"User {ctx.author.name}({ctx.author.id}) converted 1 card(s): [{card.id}]. Gained {card.cost} candies.") + func.logger.info(f"User {ctx.author.name}({ctx.author.id}) converted 1 card(s): [{card.id}]. Gained {reward_candies} candies.") embed.title="✨ Converted" await message.edit(embed=embed, view=None) if message else await ctx.reply(embed=embed) @@ -192,6 +195,7 @@ async def convertall(self, ctx: commands.Context): card_ids = [card.id for card in converted_cards] candies = sum([card.cost for card in converted_cards]) + candies = apply_contract_candy_reward(candies, user) embed = discord.Embed(title="✨ Confirm to convert?", color=discord.Color.random()) embed.description = f"```🆔 {', '.join([f'{card}' for card in converted_cards])} \n🍬 + {candies}```" @@ -254,6 +258,7 @@ async def convertmass(self, ctx: commands.Context, *, categorys: str): card_ids = [card.id for card in converted_cards] candies = sum([card.cost for card in converted_cards]) + candies = apply_contract_candy_reward(candies, user) embed = discord.Embed(title="✨ Confirm to convert?", color=discord.Color.random()) embed.description = f"```🆔 {', '.join([f'{card}' for card in converted_cards])} \n🍬 + {candies}```" diff --git a/cogs/gameplay.py b/cogs/gameplay.py index 3a2248f..c812624 100644 --- a/cogs/gameplay.py +++ b/cogs/gameplay.py @@ -3,6 +3,7 @@ import random import io, os from PIL import Image, ImageFilter +from iufi.perfect_crown import inject_perfect_crown_token, apply_contract_cooldown from discord.ext import commands from iufi.pool import QuestionPool as QP @@ -50,7 +51,8 @@ async def roll(self, ctx: commands.Context, *, tier: str = None): # Calculate soft pity boosts for rate increases soft_pity_boosts = func.calculate_soft_pity_boost(user) - query["$set"] = {"cooldown.roll": time.time() + (func.settings.COOLDOWN_BASE["roll"][1] * (1 - actived_potions.get("speed", 0)))} + roll_cooldown = func.settings.COOLDOWN_BASE["roll"][1] * (1 - actived_potions.get("speed", 0)) + query["$set"] = {"cooldown.roll": time.time() + apply_contract_cooldown(roll_cooldown, user)} else: # Purchased roll - don't affect pity @@ -94,6 +96,7 @@ async def roll(self, ctx: commands.Context, *, tier: str = None): if not tier: pity_query = func.update_pity_from_cards(user, cards) await func.update_user(ctx.author.id, pity_query) + cards = inject_perfect_crown_token(cards, user) image_bytes, image_format = await iufi.gen_cards_view(cards) @@ -125,7 +128,12 @@ async def game(self, ctx: commands.Context, level: str): view = MatchGame(ctx.author, level) actived_potions = func.get_potions(user.get("actived_potions", {}), func.settings.POTIONS_BASE) - query = func.update_quest_progress(user, f"PLAY_MATCH_GAME_LVL_{level}", query={"$set": {"cooldown.match_game": time.time() + (view._data.get("cooldown", 0) * (1 - actived_potions.get("speed", 0)))}}) + match_cooldown = view._data.get("cooldown", 0) * (1 - actived_potions.get("speed", 0)) + query = func.update_quest_progress( + user, + f"PLAY_MATCH_GAME_LVL_{level}", + query={"$set": {"cooldown.match_game": time.time() + apply_contract_cooldown(match_cooldown, user)}}, + ) await func.update_user(ctx.author.id, query) embed, file = await view.build() @@ -150,7 +158,8 @@ async def quiz(self, ctx: commands.Context): # If the cooldown is still in effect, inform the user and exit if (retry := user.get("cooldown", {}).setdefault("quiz_game", 0)) > time.time(): - price = max(5, int(QUIZ_SETTINGS['reset_price'] * ((retry - time.time()) / func.settings.COOLDOWN_BASE["quiz_game"][1]))) + quiz_cd = apply_contract_cooldown(func.settings.COOLDOWN_BASE["quiz_game"][1], user) + price = max(5, int(QUIZ_SETTINGS['reset_price'] * ((retry - time.time()) / max(1, quiz_cd)))) view = ResetAttemptView(ctx, user, price) view.response = await ctx.reply(f"{ctx.author.mention} your quiz is . If you’d like to bypass this cooldown, you can do so by paying `🍬 {price}` candies.", delete_after=20, view=view) return @@ -164,7 +173,12 @@ async def quiz(self, ctx: commands.Context): return await ctx.send("There are no questions for you right now! Please try again later.") # Update the user's cooldown time - query = func.update_quest_progress(user, "PLAY_QUIZ_GAME", query={"$set": {"cooldown.quiz_game": time.time() + func.settings.COOLDOWN_BASE["roll"][1]}}) + quiz_cooldown = apply_contract_cooldown(func.settings.COOLDOWN_BASE["roll"][1], user) + query = func.update_quest_progress( + user, + "PLAY_QUIZ_GAME", + query={"$set": {"cooldown.quiz_game": time.time() + quiz_cooldown}}, + ) await func.update_user(ctx.author.id, query) # Create the quiz view and send the initial message @@ -230,7 +244,8 @@ async def emojiquiz(self, ctx: commands.Context, category: str = None): user = await func.get_user(ctx.author.id) # reuse the quiz cooldown logic if (retry := user.get("cooldown", {}).setdefault("quiz_game", 0)) > time.time(): - price = max(5, int(EMOJI_QUIZ_SETTINGS['reset_price'] * ((retry - time.time()) / func.settings.COOLDOWN_BASE["quiz_game"][1]))) + quiz_cd = apply_contract_cooldown(func.settings.COOLDOWN_BASE["quiz_game"][1], user) + price = max(5, int(EMOJI_QUIZ_SETTINGS['reset_price'] * ((retry - time.time()) / max(1, quiz_cd)))) view = EmojiResetAttemptView(ctx, user, price) view.response = await ctx.reply(f"{ctx.author.mention} your emoji quiz is . If you’d like to bypass this cooldown, you can do so by paying `🍬 {price}` candies.", delete_after=20, view=view) return @@ -272,7 +287,12 @@ async def emojiquiz(self, ctx: commands.Context, category: str = None): # sampled is list of question dicts # set cooldown - query = func.update_quest_progress(user, "PLAY_QUIZ_GAME", query={"$set": {"cooldown.quiz_game": time.time() + func.settings.COOLDOWN_BASE["roll"][1]}}) + quiz_cooldown = apply_contract_cooldown(func.settings.COOLDOWN_BASE["roll"][1], user) + query = func.update_quest_progress( + user, + "PLAY_QUIZ_GAME", + query={"$set": {"cooldown.quiz_game": time.time() + quiz_cooldown}}, + ) await func.update_user(ctx.author.id, query) view = EmojiQuizView(ctx.author, sampled, timeout_per_question=40) diff --git a/cogs/perfect_crown.py b/cogs/perfect_crown.py new file mode 100644 index 0000000..c57a424 --- /dev/null +++ b/cogs/perfect_crown.py @@ -0,0 +1,298 @@ +import os + +import discord +import time +import functions as func + +from discord.ext import commands + +from iufi.perfect_crown import ( + apply_contract_cooldown, + ROYAL_CONTRACT_TEAMS, + ROYAL_TREASURY_TIERS, + ROYAL_TREASURY_TOKEN_COSTS, + is_royal_treasury_open, + get_user_perfect_crown_tokens, + get_treasury_cards_for_tier, +) + + +class TreasuryTierDropdown(discord.ui.Select): + def __init__(self): + options = [ + discord.SelectOption( + label=f"{item['label']} Tier", + emoji=str(item["emoji"]), + value=key, + ) + for key, item in ROYAL_TREASURY_TIERS.items() + ] + super().__init__( + placeholder="Select a treasury section...", + min_values=1, + max_values=1, + options=options, + ) + + async def callback(self, interaction: discord.Interaction): + view: RoyalTreasuryView = self.view + view.selected_tier = self.values[0] + await interaction.response.edit_message(embed=await view.build_embed(), view=view) + + +class TreasuryBuyModal(discord.ui.Modal): + def __init__(self, view: "RoyalTreasuryView"): + super().__init__(title="Buy Card From Treasury") + self.view_ref = view + self.card_index = discord.ui.TextInput( + label="Card Number (treasury order)", + placeholder="Enter index (e.g. 1, 2, 3...)", + style=discord.TextStyle.short, + required=True, + max_length=5, + ) + self.add_item(self.card_index) + + async def on_submit(self, interaction: discord.Interaction): + view = self.view_ref + tier = view.selected_tier + bot_user_id = interaction.client.user.id if interaction.client.user else None + if not bot_user_id: + return await interaction.response.send_message("Bot state not ready yet. Try again.", ephemeral=True) + + try: + index = int(str(self.card_index.value).strip()) + if index <= 0: + raise ValueError + except ValueError: + return await interaction.response.send_message("Please enter a valid positive number.", ephemeral=True) + + cards = get_treasury_cards_for_tier(tier) + if index > len(cards): + return await interaction.response.send_message( + f"Invalid card number. `{tier}` section has `{len(cards)}` entries.", + ephemeral=True, + ) + + card = cards[index - 1] + if not card: + return await interaction.response.send_message("This treasury slot has no configured card.", ephemeral=True) + + if card.owner_id != bot_user_id: + return await interaction.response.send_message( + "This card is sold out already.", ephemeral=True + ) + + user = await func.get_user(interaction.user.id) + if len(user.get("cards", [])) >= func.get_user_card_limit(user): + return await interaction.response.send_message("Your inventory is full.", ephemeral=True) + + owned_tokens = sorted(get_user_perfect_crown_tokens(user)) + token_cost = ROYAL_TREASURY_TOKEN_COSTS[tier] + if len(owned_tokens) < token_cost: + return await interaction.response.send_message( + f"You need `{token_cost}` tokens, but only have `{len(owned_tokens)}`.", + ephemeral=True, + ) + + consumed_tokens = owned_tokens[:token_cost] + actived_potions = func.get_potions(user.get("actived_potions", {}), func.settings.POTIONS_BASE) + user_query = func.update_quest_progress( + user, + ["COLLECT_ANY_CARD", f"COLLECT_{card._tier.upper()}_CARD"], + query={ + "$push": {"cards": card.id}, + "$set": { + "cooldown.claim": time.time() + ( + apply_contract_cooldown( + func.settings.COOLDOWN_BASE["claim"][1] * (1 - actived_potions.get("speed", 0)), + user, + ) + ) + }, + "$inc": {"exp": 10, "event_tokens.perfect_crown_count": -token_cost}, + "$unset": {f"event_tokens.perfect_crown.{token_id}": "" for token_id in consumed_tokens}, + } + ) + + card.change_owner(interaction.user.id) + await func.update_user(interaction.user.id, user_query) + await func.update_card(card.id, {"$set": {"owner_id": interaction.user.id}}) + + await interaction.response.send_message( + f"Purchased treasury card `#{index}` ({card.display_id}) for `{token_cost}` tokens.", + ephemeral=True, + ) + await view.message.edit(embed=await view.build_embed(), view=view) + + +class TreasuryBuyButton(discord.ui.Button): + def __init__(self): + super().__init__(label="Buy Card", emoji="🛒", style=discord.ButtonStyle.green) + + async def callback(self, interaction: discord.Interaction): + view: RoyalTreasuryView = self.view + await interaction.response.send_modal(TreasuryBuyModal(view)) + + +class RoyalTreasuryView(discord.ui.View): + def __init__(self, author: discord.Member, bot_user_id: int): + super().__init__(timeout=60) + self.author = author + self.bot_user_id = bot_user_id + self.selected_tier = "mystic" + self.message: discord.Message | None = None + self.add_item(TreasuryTierDropdown()) + self.add_item(TreasuryBuyButton()) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user == self.author + + async def on_timeout(self) -> None: + for child in self.children: + child.disabled = True + if self.message: + await self.message.edit(view=self) + + async def build_embed(self) -> discord.Embed: + user = await func.get_user(self.author.id) + token_count = len(get_user_perfect_crown_tokens(user)) + tier = self.selected_tier + tier_meta = ROYAL_TREASURY_TIERS[tier] + token_cost = ROYAL_TREASURY_TOKEN_COSTS[tier] + + embed = discord.Embed(title="🏰 Royal Treasury", color=discord.Color.gold()) + embed.description = ( + f"Perfect Crown tokens: `{token_count}`\n" + f"Current section: `{tier_meta['emoji']} {tier_meta['label']}` " + f"(cost per card: `{token_cost}` tokens)\n```" + ) + + cards = get_treasury_cards_for_tier(tier) + if not cards: + embed.description += "No cards configured for this section.\n" + else: + for idx, card in enumerate(cards, start=1): + if not card: + embed.description += f"{idx:>2}. [UNCONFIGURED]\n" + continue + + is_available = card.owner_id == self.bot_user_id + status = "AVAILABLE" if is_available else "SOLD OUT" + embed.description += f"{idx:>2}. {card.id.zfill(5)} {card.tier[0]} {status}\n" + + embed.description += "```" + embed.set_footer(text="Use 'Buy Card' and enter the treasury card number.") + embed.set_thumbnail(url=self.author.display_avatar.url) + return embed + + +class ContractTeamSelectionView(discord.ui.View): + def __init__(self, author: discord.Member): + super().__init__(timeout=60) + self.author = author + self.message: discord.Message | None = None + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user != self.author: + await interaction.response.send_message("Only the command author can choose a team here.", ephemeral=True) + return False + return True + + async def on_timeout(self) -> None: + for child in self.children: + child.disabled = True + if self.message: + await self.message.edit(view=self) + + async def _sign_contract(self, interaction: discord.Interaction, team_key: str) -> None: + user = await func.get_user(self.author.id) + if user.get("event_team"): + for child in self.children: + child.disabled = True + await interaction.response.edit_message( + content="Your contract is already signed. No turning back now!", + embed=None, + attachments=[], + view=self, + ) + self.stop() + return + + team_data = ROYAL_CONTRACT_TEAMS[team_key] + await func.update_user(self.author.id, {"$set": {"event_team": team_key}}) + + embed = discord.Embed( + title=f"🤝 Contract Signed: {team_data['label']}", + description=str(team_data["flavor_text"]), + color=discord.Color.gold() if team_key == "royal" else discord.Color.green(), + ) + embed.add_field(name="Active Buff", value=str(team_data["buff_description"]), inline=False) + + gif_name = str(team_data["success_gif_file"]) + gif_path = os.path.join(func.ROOT_DIR, "perfect_crown", gif_name) + embed.set_image(url=f"attachment://{gif_name}") + file = discord.File(gif_path, filename=gif_name) + + for child in self.children: + child.disabled = True + await interaction.response.edit_message(content=None, embed=embed, attachments=[file], view=self) + self.stop() + + @discord.ui.button(label="Team Royal", emoji="👑", style=discord.ButtonStyle.blurple) + async def choose_royal(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._sign_contract(interaction, "royal") + + @discord.ui.button(label="Team Chaebol", emoji="💸", style=discord.ButtonStyle.green) + async def choose_chaebol(self, interaction: discord.Interaction, _: discord.ui.Button): + await self._sign_contract(interaction, "chaebol") + + +class PerfectCrown(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.emoji = "👑" + self.invisible = False + + @commands.command(aliases=["treasury"]) + async def royaltreasury(self, ctx: commands.Context): + """Buy treasury cards using Perfect Crown tokens on May 16 KST. + + **Examples:** + @prefix@royaltreasury + @prefix@treasury + """ + if not is_royal_treasury_open(): + return await ctx.reply("🏰 The Royal Treasury only opens on **May 16 (KST)**.") + + view = RoyalTreasuryView(ctx.author, ctx.me.id) + view.message = await ctx.reply(embed=await view.build_embed(), view=view) + + @commands.command(aliases=["ct"]) + async def contract(self, ctx: commands.Context): + """Sign a Royal Contract with one event team. + + **Examples:** + @prefix@contract + @prefix@ct + """ + user = await func.get_user(ctx.author.id) + if user.get("event_team"): + return await ctx.reply("Your contract is already signed. No turning back now!") + + embed = discord.Embed( + title="🤝 Royal Contract", + description=( + "Choose your side for the event. **This cannot be changed later.**\n\n" + "👑 **Team Royal** — Halves your cooldowns until May 16th.\n" + "💸 **Team Chaebol** — 2× Star Candies from converting cards until May 16th." + ), + color=discord.Color.random(), + ) + view = ContractTeamSelectionView(ctx.author) + view.message = await ctx.reply(embed=embed, view=view) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(PerfectCrown(bot)) + diff --git a/cogs/profile.py b/cogs/profile.py index c22bdfe..1149af4 100644 --- a/cogs/profile.py +++ b/cogs/profile.py @@ -2,11 +2,11 @@ import functions as func from discord.ext import commands +from iufi.perfect_crown import apply_contract_cooldown from views import ( CollectionView, PhotoCardView, - WishListView, - ProfileStatsView + WishListView ) from typing import ( Dict, @@ -122,8 +122,7 @@ async def profile(self, ctx: commands.Context, member: discord.Member = None): embed.set_image(url=f"attachment://{image_name}") embed.add_field(name=func.framed_title("Showcase"), value=f"```{card}```", inline=False) - view = ProfileStatsView(ctx, member, DAILY_ROWS) - view.message = await ctx.reply(embed=embed, file=file, view=view) + await ctx.reply(embed=embed, file=file) @commands.command(aliases=["sb"]) async def setbio(self, ctx: commands.Context, *, bio: str = None): @@ -343,7 +342,10 @@ async def daily(self, ctx: commands.Context): reward = {"candies": 5} if claimed % 5 else {WEEKLY_REWARDS[(claimed//5) - 1][1]: WEEKLY_REWARDS[(claimed//5) - 1][2]} await func.update_user(ctx.author.id, { - "$set": {"claimed": claimed, "cooldown.daily": time.time() + func.settings.COOLDOWN_BASE["daily"][1]}, + "$set": { + "claimed": claimed, + "cooldown.daily": time.time() + apply_contract_cooldown(func.settings.COOLDOWN_BASE["daily"][1], user), + }, "$inc": reward }) @@ -399,6 +401,19 @@ async def inventory(self, ctx: commands.Context): emoji, _ = func.settings.TIERS_BASE.get(tier) embed.description += f"{emoji} {tier.title() + ' Rolls':<18} x{count}\n" + perfect_crown_tokens = user.get("event_tokens", {}).get("perfect_crown", {}) + perfect_crown_count = user.get("event_tokens", {}).get( + "perfect_crown_count", + len([token_id for token_id, owned in perfect_crown_tokens.items() if owned]) + ) + if perfect_crown_count > 0: + owned_ids = sorted([token_id.replace("pc_ep", "EP") for token_id, owned in perfect_crown_tokens.items() if owned]) + embed.description += f"👑 {'Perfect Crown':<18} x{perfect_crown_count}/12\n" + embed.description += f" {', '.join(owned_ids[:6])}" + if len(owned_ids) > 6: + embed.description += " ..." + embed.description += "\n" + embed.description += f"\n\n" potions_data: dict[str, int] = user.get("potions", {}) diff --git a/cover/level1.webp b/cover/level1.webp index 1158e64..a6ddb9f 100644 Binary files a/cover/level1.webp and b/cover/level1.webp differ diff --git a/cover/level2.webp b/cover/level2.webp index 10abb09..3d4c8ae 100644 Binary files a/cover/level2.webp and b/cover/level2.webp differ diff --git a/cover/level3.webp b/cover/level3.webp index f5e74b7..2432266 100644 Binary files a/cover/level3.webp and b/cover/level3.webp differ diff --git a/functions.py b/functions.py index d8368b7..8aa35cb 100644 --- a/functions.py +++ b/functions.py @@ -81,6 +81,7 @@ def __init__(self): # Newly added defaults so callers can reference them directly without getattr self.PVP_SETTINGS: Dict[str, Any] = {} self.REWARD_CARD_PROBABILITIES: Dict[str, Any] = {} + self.PERFECT_CROWN_TREASURY_CARDS: Dict[str, List[str]] = {} def load(self): settings = open_json("settings.json") @@ -119,6 +120,7 @@ def load(self): self.MONTHLY_LEADERBOARD_ROLE = settings.get("MONTHLY_LEADERBOARD_ROLE", 0) self.PVP_SETTINGS = settings.get("PVP_SETTINGS", {}) self.REWARD_CARD_PROBABILITIES = settings.get("REWARD_CARD_PROBABILITIES", {}) + self.PERFECT_CROWN_TREASURY_CARDS = settings.get("PERFECT_CROWN_TREASURY_CARDS", {}) tokens: TOKEN = TOKEN() settings: Settings = Settings() @@ -454,8 +456,9 @@ async def update_user(user_id: int, data: dict) -> None: for key in list(incs.keys()): if key.count('.') >= 2 and key.split('.')[0] == 'game_state' and key.split('.')[-1] == 'points': # e.g. game_state.music_game.points -> game_state.music_game.monthly_points + # (parent path must exclude `points`; otherwise Mongo conflicts: points vs points.monthly_points) parts = key.split('.') - game_path = '.'.join(parts[:3]) # game_state. + game_path = '.'.join(parts[:-1]) monthly_points_key = f"{game_path}.monthly_points" last_update_key = f"{game_path}.last_update" data.setdefault('$inc', {})[monthly_points_key] = data['$inc'].get(key, 0) @@ -468,7 +471,11 @@ async def update_user(user_id: int, data: dict) -> None: nested_user = user for c in cursors[:-1]: - nested_user = nested_user.setdefault(c, {}) + nxt = nested_user.get(c) + if not isinstance(nxt, dict): + nxt = {} + nested_user[c] = nxt + nested_user = nxt if mode == "$set": try: diff --git a/iufi/perfect_crown.py b/iufi/perfect_crown.py new file mode 100644 index 0000000..79e4999 --- /dev/null +++ b/iufi/perfect_crown.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import asyncio +import random +from datetime import datetime, timedelta +from io import BytesIO +from zoneinfo import ZoneInfo + +import functions as func +from PIL import Image + +from .objects import TempCard + +KST = ZoneInfo("Asia/Seoul") + +PERFECT_CROWN_INJECT_PROBABILITY = 0.08 +PERFECT_CROWN_MISSING_WEIGHT = 6 + +ROYAL_TREASURY_DATE = datetime(2026, 5, 16, tzinfo=KST).date() + +ROYAL_TREASURY_TIERS: dict[str, dict[str, str]] = { + "mystic": {"label": "Mystic", "emoji": "🦄"}, + "legendary": {"label": "Legendary", "emoji": "👑"}, + "epic": {"label": "Epic", "emoji": "💎"}, +} + +ROYAL_TREASURY_TOKEN_COSTS: dict[str, int] = { + "mystic": 12, + "legendary": 5, + "epic": 3, +} + +ROYAL_CONTRACT_TEAMS: dict[str, dict[str, str | float]] = { + "royal": { + "label": "Team Royal", + "buff_description": "Halves all your cooldowns until May 16th", + "cooldown_multiplier": 0.5, + "candy_multiplier": 1.0, + "success_gif_file": "time.gif", + "flavor_text": ( + "The throne waits for no one. Your royal decree has granted " + "you the gift of time. Welcome to Team Royal." + ), + }, + "chaebol": { + "label": "Team Chaebol", + "buff_description": "2x Star Candies rewards from converting cards until May 16th", + "cooldown_multiplier": 1.0, + "candy_multiplier": 2.0, + "success_gif_file": "money.gif", + "flavor_text": ( + "Contracts are signed in gold, not blood. And " + "Power isn't inherited, it's built. Welcome to Team Chaebol." + ), + }, +} + +IU_BIRTHDAY_BLESSING_DATE = datetime(2026, 5, 16, tzinfo=KST).date() + +PERFECT_CROWN_RELEASES: list[dict[str, str | datetime]] = [ + {"id": "pc_ep01", "label": "Episode 01", "release_at": datetime(2026, 4, 10, tzinfo=KST)}, + {"id": "pc_ep02", "label": "Episode 02", "release_at": datetime(2026, 4, 11, tzinfo=KST)}, + {"id": "pc_ep03", "label": "Episode 03", "release_at": datetime(2026, 4, 17, tzinfo=KST)}, + {"id": "pc_ep04", "label": "Episode 04", "release_at": datetime(2026, 4, 18, tzinfo=KST)}, + {"id": "pc_ep05", "label": "Episode 05", "release_at": datetime(2026, 4, 24, tzinfo=KST)}, + {"id": "pc_ep06", "label": "Episode 06", "release_at": datetime(2026, 4, 25, tzinfo=KST)}, + {"id": "pc_ep07", "label": "Episode 07", "release_at": datetime(2026, 5, 1, tzinfo=KST)}, + {"id": "pc_ep08", "label": "Episode 08", "release_at": datetime(2026, 5, 2, tzinfo=KST)}, + {"id": "pc_ep09", "label": "Episode 09", "release_at": datetime(2026, 5, 8, tzinfo=KST)}, + {"id": "pc_ep10", "label": "Episode 10", "release_at": datetime(2026, 5, 9, tzinfo=KST)}, + {"id": "pc_ep11", "label": "Episode 11", "release_at": datetime(2026, 5, 15, tzinfo=KST)}, + {"id": "pc_ep12", "label": "Episode 12", "release_at": datetime(2026, 5, 16, tzinfo=KST)}, +] + +PERFECT_CROWN_INDEX: dict[str, dict[str, str | datetime]] = { + token["id"]: token for token in PERFECT_CROWN_RELEASES +} + + +def now_kst() -> datetime: + return datetime.now(KST) + + +def is_royal_treasury_open(now: datetime | None = None) -> bool: + now = now or now_kst() + return now.date() == ROYAL_TREASURY_DATE + + +def get_user_event_team(user: dict) -> str | None: + event_team = user.get("event_team") + if isinstance(event_team, str): + normalized = event_team.strip().lower() + if normalized in ROYAL_CONTRACT_TEAMS: + return normalized + return None + + +def is_iu_birthday_blessing_active(now: datetime | None = None) -> bool: + now = now or now_kst() + return now.date() == IU_BIRTHDAY_BLESSING_DATE + + +def get_contract_buffs(user: dict, now: datetime | None = None) -> tuple[float, float]: + now = now or now_kst() + if is_iu_birthday_blessing_active(now): + return 0.5, 2.0 + + event_team = get_user_event_team(user) + if not event_team: + return 1.0, 1.0 + + team_data = ROYAL_CONTRACT_TEAMS[event_team] + cooldown_multiplier = float(team_data.get("cooldown_multiplier", 1.0)) + candy_multiplier = float(team_data.get("candy_multiplier", 1.0)) + return cooldown_multiplier, candy_multiplier + + +def apply_contract_cooldown(base_seconds: int | float, user: dict, now: datetime | None = None) -> int: + if base_seconds <= 0: + return int(base_seconds) + + cooldown_multiplier, _ = get_contract_buffs(user, now) + adjusted = base_seconds * cooldown_multiplier + return max(1, int(adjusted)) + + +def apply_contract_candy_reward(base_candies: int | float, user: dict, now: datetime | None = None) -> int: + if base_candies <= 0: + return int(base_candies) + + _, candy_multiplier = get_contract_buffs(user, now) + adjusted = base_candies * candy_multiplier + return max(0, int(adjusted)) + + +def get_user_perfect_crown_tokens(user: dict) -> set[str]: + event_tokens = user.get("event_tokens", {}).get("perfect_crown", {}) + if isinstance(event_tokens, dict): + return {token_id for token_id, owned in event_tokens.items() if owned} + return set() + + +def get_active_token_pool(now: datetime | None = None) -> list[dict[str, str | datetime]]: + now = now or now_kst() + if is_royal_treasury_open(now): + return PERFECT_CROWN_RELEASES.copy() + + active_tokens: list[dict[str, str | datetime]] = [] + for token in PERFECT_CROWN_RELEASES: + release_at = token["release_at"] + if not isinstance(release_at, datetime): + continue + if release_at <= now <= (release_at + timedelta(days=7)): + active_tokens.append(token) + return active_tokens + + +def get_weighted_pool_for_user(user: dict, now: datetime | None = None) -> tuple[list[dict[str, str | datetime]], list[int]]: + pool = get_active_token_pool(now) + if not pool: + return [], [] + + owned = get_user_perfect_crown_tokens(user) + is_finale = is_royal_treasury_open(now) + + weighted: list[int] = [] + for token in pool: + token_id = token["id"] + if not isinstance(token_id, str): + weighted.append(1) + continue + + if is_finale and token_id not in owned: + weighted.append(PERFECT_CROWN_MISSING_WEIGHT) + else: + weighted.append(1) + + return pool, weighted + + +def should_inject_token(probability: float = PERFECT_CROWN_INJECT_PROBABILITY) -> bool: + return random.random() < probability + + +class PerfectCrownToken: + def __init__(self, token_id: str, label: str, release_at: datetime): + self.id = token_id + self.label = label + self.release_at = release_at + self.owner_id: int | None = None + self.is_gif: bool = False + self.is_perfect_crown_token = True + self._lock: asyncio.Lock = asyncio.Lock() + + @property + def tier(self) -> tuple[str, str]: + return "👑", "perfect_crown" + + @property + def format(self) -> str: + return "webp" + + @property + def display_id(self) -> str: + return f"🆔 {self.id.upper()}" + + @property + def display_stars(self) -> str: + return "🎁 TOKEN" + + @property + def display_tag(self) -> str: + return f"🏷️ {self.label}" + + @property + def display_frame(self) -> str: + return "🖼️ EVENT" + + def change_owner(self, owner_id: int | None = None) -> None: + self.owner_id = owner_id + + async def image(self, *, size_rate: float = 0.2, hide_image_if_no_owner: bool = False) -> Image.Image | list[Image.Image]: + async with self._lock: + token_image = TempCard(f"perfect_crown/{self.id}.webp") + try: + return await token_image.image(size_rate=size_rate) + except Exception: + fallback = TempCard("cover/level1.webp") + return await fallback.image(size_rate=size_rate) + + async def image_bytes(self) -> BytesIO: + image = await self.image() + image_bytes = BytesIO() + if isinstance(image, list): + image[0].save(image_bytes, format="WEBP", save_all=True, append_images=image[1:], loop=0, duration=100, optimize=False) + else: + image.save(image_bytes, format="WEBP") + image_bytes.seek(0) + return image_bytes + + def __str__(self) -> str: + return f"👑 {self.label}" + + +def create_token(token_data: dict[str, str | datetime]) -> PerfectCrownToken: + token_id = str(token_data["id"]) + label = str(token_data["label"]) + release_at = token_data["release_at"] + if not isinstance(release_at, datetime): + release_at = now_kst() + return PerfectCrownToken(token_id=token_id, label=label, release_at=release_at) + + +def inject_perfect_crown_token(cards: list, user: dict, probability: float = PERFECT_CROWN_INJECT_PROBABILITY, now: datetime | None = None) -> list: + if not cards or not should_inject_token(probability): + return cards + + pool, weights = get_weighted_pool_for_user(user, now) + if not pool: + return cards + + selected = random.choices(pool, weights=weights, k=1)[0] + token = create_token(selected) + replace_index = random.randint(0, len(cards) - 1) + cards[replace_index] = token + return cards + + +def build_perfect_crown_claim_update(token_id: str) -> dict: + return { + "$set": {f"event_tokens.perfect_crown.{token_id}": True}, + "$inc": {"event_tokens.perfect_crown_count": 1}, + } + + +def get_treasury_card_ids_for_tier(tier: str) -> list[str]: + cards_by_tier = func.settings.PERFECT_CROWN_TREASURY_CARDS or {} + return [str(card_id) for card_id in cards_by_tier.get(tier, [])] + + +def get_treasury_cards_for_tier(tier: str) -> list: + from .pool import CardPool + + cards: list = [] + for card_id in get_treasury_card_ids_for_tier(tier): + cards.append(CardPool.get_card(card_id)) + return cards + + +async def ensure_royal_treasury_cards_claimed(bot_user_id: int) -> dict[str, int]: + from .pool import CardPool + + synced = 0 + skipped = 0 + + for tier in ROYAL_TREASURY_TIERS.keys(): + for card in get_treasury_cards_for_tier(tier): + if not card: + skipped += 1 + continue + + if card.owner_id is None: + card.change_owner(bot_user_id) + try: + CardPool.remove_available_card(card) + except Exception: + pass + await func.update_card(card.id, {"$set": {"owner_id": bot_user_id}}) + synced += 1 + elif card.owner_id == bot_user_id: + continue + else: + skipped += 1 + + return {"synced": synced, "skipped": skipped} + diff --git a/main.py b/main.py index cb638ad..b13d86e 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ class IUFI(commands.Bot): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._treasury_sync_done = False async def on_message(self, message: discord.Message, /) -> None: # Ignore messages from bots or outside of guilds @@ -91,6 +92,15 @@ async def setup_hook(self) -> None: func.logger.info(f"Loaded {module[:-3]}") async def on_ready(self): + if not self._treasury_sync_done and self.user: + from iufi.perfect_crown import ensure_royal_treasury_cards_claimed + + sync_result = await ensure_royal_treasury_cards_claimed(self.user.id) + self._treasury_sync_done = True + func.logger.info( + f"Royal treasury sync complete. claimed={sync_result.get('synced', 0)} skipped={sync_result.get('skipped', 0)}" + ) + func.logger.info("------------------") func.logger.info(f"Logging As {self.user}") func.logger.info(f"Bot ID: {self.user.id}") diff --git a/perfect_crown/money.gif b/perfect_crown/money.gif new file mode 100644 index 0000000..4391b46 Binary files /dev/null and b/perfect_crown/money.gif differ diff --git a/perfect_crown/pc_ep01.webp b/perfect_crown/pc_ep01.webp new file mode 100644 index 0000000..6019ce2 Binary files /dev/null and b/perfect_crown/pc_ep01.webp differ diff --git a/perfect_crown/pc_ep02.webp b/perfect_crown/pc_ep02.webp new file mode 100644 index 0000000..e7f7a74 Binary files /dev/null and b/perfect_crown/pc_ep02.webp differ diff --git a/perfect_crown/pc_ep03.webp b/perfect_crown/pc_ep03.webp new file mode 100644 index 0000000..07fc596 Binary files /dev/null and b/perfect_crown/pc_ep03.webp differ diff --git a/perfect_crown/pc_ep04.webp b/perfect_crown/pc_ep04.webp new file mode 100644 index 0000000..15ce0c8 Binary files /dev/null and b/perfect_crown/pc_ep04.webp differ diff --git a/perfect_crown/pc_ep05.webp b/perfect_crown/pc_ep05.webp new file mode 100644 index 0000000..0c45ea8 Binary files /dev/null and b/perfect_crown/pc_ep05.webp differ diff --git a/perfect_crown/pc_ep06.webp b/perfect_crown/pc_ep06.webp new file mode 100644 index 0000000..27ed030 Binary files /dev/null and b/perfect_crown/pc_ep06.webp differ diff --git a/perfect_crown/pc_ep07.webp b/perfect_crown/pc_ep07.webp new file mode 100644 index 0000000..ed2f89e Binary files /dev/null and b/perfect_crown/pc_ep07.webp differ diff --git a/perfect_crown/pc_ep08.webp b/perfect_crown/pc_ep08.webp new file mode 100644 index 0000000..8f66fbc Binary files /dev/null and b/perfect_crown/pc_ep08.webp differ diff --git a/perfect_crown/pc_ep09.webp b/perfect_crown/pc_ep09.webp new file mode 100644 index 0000000..9301acf Binary files /dev/null and b/perfect_crown/pc_ep09.webp differ diff --git a/perfect_crown/pc_ep10.webp b/perfect_crown/pc_ep10.webp new file mode 100644 index 0000000..a819ef9 Binary files /dev/null and b/perfect_crown/pc_ep10.webp differ diff --git a/perfect_crown/pc_ep11.webp b/perfect_crown/pc_ep11.webp new file mode 100644 index 0000000..18bf32c Binary files /dev/null and b/perfect_crown/pc_ep11.webp differ diff --git a/perfect_crown/pc_ep12.webp b/perfect_crown/pc_ep12.webp new file mode 100644 index 0000000..a38dd78 Binary files /dev/null and b/perfect_crown/pc_ep12.webp differ diff --git a/perfect_crown/time.gif b/perfect_crown/time.gif new file mode 100644 index 0000000..8d1277a Binary files /dev/null and b/perfect_crown/time.gif differ diff --git a/settings.json b/settings.json index 54ea353..270372f 100644 --- a/settings.json +++ b/settings.json @@ -15,6 +15,11 @@ "GAME_CHANNEL_IDS": [1155772660979093555, 1155772719909044224, 1155772738858913792, 1155772756730859580], "ADMIN_IDS": [605776164744724512, 705783356767338648, 1223531350972174337], "BUG_REPORT_CHANNEL_ID": 1384723383131967622, + "PERFECT_CROWN_TREASURY_CARDS": { + "mystic": [], + "legendary": [], + "epic": [] + }, "OPUS_PATH": "", "USER_BASE": { "candies": 0, @@ -63,7 +68,12 @@ "extra_props": { "extra_card_slots": 0, "slot_purchases": 0 - } + }, + "event_tokens": { + "perfect_crown": {}, + "perfect_crown_count": 0 + }, + "event_team": null }, "PITY_SETTINGS": { "rare": { diff --git a/views/__init__.py b/views/__init__.py index 5ebc1ea..95171bd 100644 --- a/views/__init__.py +++ b/views/__init__.py @@ -19,4 +19,3 @@ def __init__(self, retry_after: float) -> None: from .musiclearderboard import MusicLeaderboardView from .wishlist import WishListView from .reward_card import RewardCardView -from .profile_status import ProfileStatsView diff --git a/views/quiz.py b/views/quiz.py index 7f18e6f..283044f 100644 --- a/views/quiz.py +++ b/views/quiz.py @@ -226,6 +226,8 @@ async def end_game(self) -> None: # Feature flag: Use reward card system or traditional rewards use_reward_card = func.settings.GIVE_REWARD_CARD + state["points"] = 10 + if not use_reward_card: # Traditional reward system (promotion rewards) if new_record: diff --git a/views/roll.py b/views/roll.py index a5882c9..b40d29b 100644 --- a/views/roll.py +++ b/views/roll.py @@ -2,6 +2,7 @@ import functions as func from iufi import CardPool, Card +from iufi.perfect_crown import build_perfect_crown_claim_update, apply_contract_cooldown class RollButton(discord.ui.Button): def __init__(self, card: Card, **kwargs): @@ -16,6 +17,7 @@ def __init__(self, card: Card, **kwargs): async def callback(self, interaction: discord.Interaction) -> None: async with self.view._lock: + is_perfect_crown_token = bool(getattr(self.card, "is_perfect_crown_token", False)) if (owner_id := self.card.owner_id): if owner_id != interaction.user.id: return await interaction.response.send_message(f"{interaction.user.mention} This card has been claimed by <@{owner_id}>") @@ -37,17 +39,42 @@ async def callback(self, interaction: discord.Interaction) -> None: self.style = discord.ButtonStyle.gray self.card.change_owner(interaction.user.id) - CardPool.remove_available_card(self.card) + if not is_perfect_crown_token: + CardPool.remove_available_card(self.card) await interaction.response.defer() - actived_potions = func.get_potions(user.get("actived_potions", {}), func.settings.POTIONS_BASE) - query = func.update_quest_progress(user, ["COLLECT_ANY_CARD", f"COLLECT_{self.card._tier.upper()}_CARD"], query={ - "$push": {"cards": self.card.id}, - "$set": {"cooldown.claim": time.time() + (func.settings.COOLDOWN_BASE["claim"][1] * (1 - actived_potions.get("speed", 0)))}, - "$inc": {"exp": 10} - }) - await func.update_user(interaction.user.id, query) - await func.update_card(self.card.id, {"$set": {"owner_id": interaction.user.id}}) + if is_perfect_crown_token: + token_id = self.card.id + owned_tokens = user.get("event_tokens", {}).get("perfect_crown", {}) + if owned_tokens.get(token_id): + self.card.change_owner(None) + self.disabled = False + self.style = discord.ButtonStyle.green + return await interaction.followup.send(f"{interaction.user.mention} You already own `{token_id.upper()}`.", ephemeral=True) + + token_query = build_perfect_crown_claim_update(token_id) + actived_potions = func.get_potions(user.get("actived_potions", {}), func.settings.POTIONS_BASE) + token_query.setdefault("$set", {})["cooldown.claim"] = time.time() + ( + apply_contract_cooldown( + func.settings.COOLDOWN_BASE["claim"][1] * (1 - actived_potions.get("speed", 0)), + user, + ) + ) + await func.update_user(interaction.user.id, token_query) + else: + actived_potions = func.get_potions(user.get("actived_potions", {}), func.settings.POTIONS_BASE) + query = func.update_quest_progress(user, ["COLLECT_ANY_CARD", f"COLLECT_{self.card._tier.upper()}_CARD"], query={ + "$push": {"cards": self.card.id}, + "$set": { + "cooldown.claim": time.time() + apply_contract_cooldown( + func.settings.COOLDOWN_BASE["claim"][1] * (1 - actived_potions.get("speed", 0)), + user, + ) + }, + "$inc": {"exp": 10} + }) + await func.update_user(interaction.user.id, query) + await func.update_card(self.card.id, {"$set": {"owner_id": interaction.user.id}}) func.logger.info(f"User {interaction.user.name}({interaction.user.id}) has successfully claimed the card: [{self.card.id}].")