Skip to content
Open
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
9697497
Add per-cooldown reminder toggles and update tasks
Arieswaran Nov 1, 2025
90b88c4
Add Emoji Quiz game and leaderboard
Arieswaran Nov 1, 2025
1f87d26
Add string similarity functions and improve quiz answer checking
Arieswaran Nov 1, 2025
e21eaff
Add reward card system to emoji quiz
Arieswaran Nov 3, 2025
53d74dd
Add logging for quiz and reward card events
Arieswaran Nov 3, 2025
6e81179
Add PvP challenge and match system
Arieswaran Nov 3, 2025
ee5d091
Enhance PvP match with round previews and auto-test
Arieswaran Nov 3, 2025
73858ee
Enhance PvP winner highlight and add post-round delay
Arieswaran Nov 3, 2025
d6a8e9b
Add DejaVuSans and Roboto font files
Arieswaran Nov 3, 2025
a645d55
Add RPS faction bonus to PvP match logic
Arieswaran Nov 3, 2025
88aa461
Add PvP reward selection with hidden card covers
Arieswaran Nov 3, 2025
5ac8c5b
Add special message for missed mystic/celestial drops
Arieswaran Nov 3, 2025
e57e4f9
Add 'Guess the IU MV' game and leaderboard
Arieswaran Nov 3, 2025
b80b9a6
Improve segment extraction and add debug saving
Arieswaran Nov 3, 2025
badf440
Add dynamic blur reduction to MV guessing game
Arieswaran Nov 3, 2025
e5ec886
Improve MV guess game UX and adjust blur logic
Arieswaran Nov 3, 2025
bce485f
Add popularity weighting to quiz sampling
Arieswaran Nov 4, 2025
c4d2570
Expand quiz content and adjust game settings
Arieswaran Nov 4, 2025
de43a0b
Improve answer timing and validation in emoji quiz
Arieswaran Nov 4, 2025
b403b58
Add PvP rewards toggle to settings
Arieswaran Nov 10, 2025
3add744
Add PVP stats tracking and leaderboard
Arieswaran Nov 10, 2025
ae17a46
Initial plan
Copilot Nov 10, 2025
5a5c650
Add reward card system with feature flag for all game types
Copilot Nov 10, 2025
046245c
Move emoji quiz reward probabilities to settings.json
Copilot Nov 10, 2025
81e309e
Merge pull request #73 from ChocoMeow/copilot/add-reward-card-settings
Arieswaran Nov 10, 2025
9b0fd1a
Refactor reward card feature flag and settings access
Arieswaran Nov 10, 2025
a1f4cfe
Add scalable inventory slot purchases to shop
Arieswaran Nov 10, 2025
0baffd4
Fix card reset threshold and cache clear interval
Arieswaran Nov 10, 2025
5ad9c2d
Implement monthly leaderboards and stats reset
Arieswaran Nov 11, 2025
6922fa0
Add admin cog and enhance profile and stats UI
Arieswaran Nov 11, 2025
e92d816
Refactor settings access and monthly leaderboard roles
Arieswaran Nov 16, 2025
1920482
Update settings.json
Arieswaran Nov 21, 2025
5d50fc2
Update level cover images
Arieswaran Nov 22, 2025
6f2b117
Enable processing of new cards in CardPool
Arieswaran Nov 28, 2025
2e5b8c8
Merge branch 'main' into aries/QoL
Arieswaran Nov 28, 2025
75ae441
Update inventory slot purchase logic and pricing
Arieswaran Nov 28, 2025
be5a417
Update shop price calculation logic
Arieswaran Nov 30, 2025
0bf2b35
Update achievements, MV data, and task rewards
Arieswaran Nov 30, 2025
6e76182
Enforce PvP team tier rules and update reward card embed
Arieswaran Nov 30, 2025
1bd8dda
Remove leaderboard buttons from EmojiLeaderboardView
Arieswaran Nov 30, 2025
12a942e
Update pvp.py
Arieswaran Nov 30, 2025
990dede
Enable monthly reward distribution and update song type
Arieswaran Dec 1, 2025
7ecfb3d
Update and expand song emoji mappings
Arieswaran Dec 3, 2025
181ba5a
add smol to admin
Arieswaran Dec 10, 2025
42bb9a4
remove admin access
Arieswaran Dec 11, 2025
331021c
Move admin commands to developer cog and refactor
ChocoMeow Dec 11, 2025
e604c52
Handle CheckFailure in command error handler
ChocoMeow Dec 13, 2025
4da47b3
music quiz song selection logic change
Arieswaran Dec 13, 2025
de5fa24
Refactor leaderboard commands and update dependencies
ChocoMeow Dec 15, 2025
b2df09c
Refactor profile stats view and add framed title utility
ChocoMeow Dec 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions cogs/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import discord
from discord.ext import commands
import functions as func
import iufi
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module 'iufi' is imported with both 'import' and 'import from'.

Copilot uses AI. Check for mistakes.
from iufi import CardPool
from views import ConfirmView


def is_admin_account(user_id) -> bool:
return True
if user_id in func.settings.ADMIN_IDS:
return True
return False


Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The is_admin_account function always returns True on line 10, bypassing the actual admin check on lines 11-12. This means any user can execute admin commands like givecandies, removeCardFromUser, quit, etc., which is a critical security vulnerability.

Remove line 10 or change it to return False (or remove the function entirely and use the check directly).

Suggested change
return True
if user_id in func.settings.ADMIN_IDS:
return True
return False
if user_id in func.settings.ADMIN_IDS:
return True
return False

Copilot uses AI. Check for mistakes.
class Admin(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.emoji = "🔧"
self.invisible = False

@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()
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?"
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in the warning message: "Conver" should be "Convert".

Suggested change
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?"
embed.description = f"**WARNING: This action cannot be undone!**\n\nThis will:\n- Convert 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?"

Copilot uses AI. Check for mistakes.

# 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])
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable candies is not used.

Copilot uses AI. Check for mistakes.

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)


async def setup(bot: commands.Bot):
await bot.add_cog(Admin(bot))
117 changes: 116 additions & 1 deletion cogs/gameplay.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import discord, iufi, time, asyncio
import functions as func
import random
import io, os
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'io' is not used.

Suggested change
import io, os
import os

Copilot uses AI. Check for mistakes.
from PIL import Image, ImageFilter
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'Image' is not used.
Import of 'ImageFilter' is not used.

Suggested change
from PIL import Image, ImageFilter

Copilot uses AI. Check for mistakes.

from discord.ext import commands
from iufi.pool import QuestionPool as QP
Expand All @@ -9,8 +12,10 @@
MatchGame,
QuizView,
ResetAttemptView,
QUIZ_SETTINGS
QUIZ_SETTINGS,
)
from views.emoji_quiz import EmojiQuizView, EmojiResetAttemptView, EMOJI_QUIZ_SETTINGS
from views.pvp import ChallengeView, get_pvp_settings, PvPMatch

class Gameplay(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
Expand Down Expand Up @@ -211,6 +216,116 @@ async def shop(self, ctx: commands.Context):
view = ShopView(ctx.author)
view.message = await ctx.reply(embed=await view.build_embed(), view=view)

@commands.command(aliases=["eq"])
async def emojiquiz(self, ctx: commands.Context, category: str = None):
"""Guess IU song or drama by emoji(s).

Optional `category` can be `song` or `drama` to restrict questions.

**Examples:**
@prefix@emojiquiz
@prefix@eq song
@prefix@eq drama
"""
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])))
view = EmojiResetAttemptView(ctx, user, price)
view.response = await ctx.reply(f"{ctx.author.mention} your emoji quiz is <t:{round(retry)}:R>. If you’d like to bypass this cooldown, you can do so by paying `🍬 {price}` candies.", delete_after=20, view=view)
return

# load emoji entries from JSON file
try:
import json
with open(os.path.join(func.ROOT_DIR, "data", "song_emojis.json"), encoding="utf8") as f:
entries = json.load(f)
except Exception:
entries = []

if not entries:
return await ctx.reply("There are no emoji entries available right now.")

# Normalize category param and filter entries if provided
category = category.lower() if category else None
if category and category not in ("song", "drama"):
return await ctx.reply("Invalid category. Please use `song` or `drama`.")

filtered = [e for e in entries if (not category) or (e.get("type", "song").lower() == category)]
if not filtered:
return await ctx.reply(f"No entries found for category: {category}")

num_q = min(5, len(filtered))
# Weighted sampling without replacement based on 'popularity' (1..10). Default popularity=5.
try:
items = filtered.copy()
sampled = []
for _ in range(num_q):
weights = [max(1, min(10, int(e.get("popularity", 5)))) for e in items]
chosen = random.choices(items, weights=weights, k=1)[0]
sampled.append(chosen)
# remove the chosen item for subsequent picks
items.remove(chosen)
except Exception:
# fallback to uniform sampling
sampled = random.sample(filtered, k=num_q)
# 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]}})
await func.update_user(ctx.author.id, query)

view = EmojiQuizView(ctx.author, sampled, timeout_per_question=40)
view.response = await ctx.reply(
content=f"**This game ends** <t:{round(time.time() + view.total_time)}:R>",
embed=view.build_embed(),
view=view
)

# start the view runner to manage per-question timeouts
asyncio.create_task(view.run())

await asyncio.sleep(view.total_time)
await view.end_game()

@commands.command()
async def pvp(self, ctx: commands.Context, opponent: discord.Member = None):
"""Issue a PvP challenge. If opponent is omitted, the challenge is open for anyone to accept.

Example:
@prefix@pvp @user
@prefix@pvp
"""
# create challenge view and message
view = ChallengeView(ctx, ctx.author, opponent, timeout=get_pvp_settings().get("challenge_timeout", 300))
view.message = await ctx.reply(f"{ctx.author.mention} issued a PvP challenge{' to ' + opponent.mention if opponent else ''}. Expires in <t:{round(time.time() + get_pvp_settings().get('challenge_timeout', 300))}:R>", view=view)

@commands.command(name="pvp_test", aliases=["pvptest", "pvp_auto"])
async def pvp_test(self, ctx: commands.Context):
"""Test command: auto-start a PvP match using random cards for you and the bot, and play it through."""
# Ensure the card pool is loaded
try:
# pick random cards for each side
cards_a = iufi.CardPool.get_random_cards_for_match_game(3)
cards_b = iufi.CardPool.get_random_cards_for_match_game(3)
except Exception as e:
return await ctx.reply(f"Failed to pick random cards for test: {e}")

opponent = ctx.guild.me
settings = get_pvp_settings()
match = PvPMatch(ctx, ctx.author, opponent, settings)

# send a starter message to attach match outputs to
starter = await ctx.send(f"Starting automated PvP test: {ctx.author.mention} vs {opponent.mention}")
match.message = starter

# assign teams directly (bypass modal/ownership checks for testing)
match.teams[ctx.author.id] = cards_a
match.teams[opponent.id] = cards_b

# run the match and wait for it to complete
await match.run()
await ctx.send("Automated PvP test finished.")
@commands.command(aliases=["mypity"], hidden=True)
async def pity(self, ctx: commands.Context, member: discord.Member = None):
"""Shows pity progress for each tier. Admin only command.
Expand Down
Loading