diff --git a/aethex-bot/bot.js b/aethex-bot/bot.js index 96e1e45..8881593 100644 --- a/aethex-bot/bot.js +++ b/aethex-bot/bot.js @@ -80,11 +80,14 @@ async function trackCommandForAchievements(discordUserId, guildId, member, supab if (!link) return; - const { updateUserStats, getUserStats, calculateLevel } = require('./listeners/xpTracker'); + const { updateUserStats, getUserStats, calculateLevel, updateQuestProgress } = require('./listeners/xpTracker'); const { checkAchievements } = require('./commands/achievements'); await updateUserStats(supabaseClient, link.user_id, guildId, { commandsUsed: 1 }); + // Track quest progress for command usage + await updateQuestProgress(supabaseClient, link.user_id, guildId, 'commands', 1); + const { data: profile } = await supabaseClient .from('user_profiles') .select('xp, prestige_level, total_xp_earned, daily_streak') diff --git a/aethex-bot/commands/daily.js b/aethex-bot/commands/daily.js index 5830ea9..21be54d 100644 --- a/aethex-bot/commands/daily.js +++ b/aethex-bot/commands/daily.js @@ -1,6 +1,6 @@ const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); const { checkAchievements } = require('./achievements'); -const { getUserStats, calculateLevel } = require('../listeners/xpTracker'); +const { getUserStats, calculateLevel, updateQuestProgress } = require('../listeners/xpTracker'); const DAILY_XP = 50; const STREAK_BONUS = 10; @@ -134,6 +134,14 @@ module.exports = { await checkAchievements(link.user_id, interaction.member, stats, supabase, guildId, client); + // Track quest progress for daily claims and XP earned + await updateQuestProgress(supabase, link.user_id, guildId, 'daily_claims', 1); + await updateQuestProgress(supabase, link.user_id, guildId, 'xp_earned', totalXp); + + if (newLevel > oldLevel) { + await updateQuestProgress(supabase, link.user_id, guildId, 'level_ups', 1); + } + } catch (error) { console.error('Daily error:', error); await interaction.editReply({ content: 'Failed to claim daily reward.' }); diff --git a/aethex-bot/commands/quests-manage.js b/aethex-bot/commands/quests-manage.js new file mode 100644 index 0000000..d55d6c8 --- /dev/null +++ b/aethex-bot/commands/quests-manage.js @@ -0,0 +1,605 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); + +const QUEST_TYPES = { + daily: { emoji: '☀️', name: 'Daily' }, + weekly: { emoji: '📅', name: 'Weekly' }, + special: { emoji: '⭐', name: 'Special' } +}; + +const OBJECTIVES = { + messages: { emoji: '💬', name: 'Send Messages' }, + reactions: { emoji: '😄', name: 'Add Reactions' }, + voice_minutes: { emoji: '🎙️', name: 'Voice Chat (minutes)' }, + commands: { emoji: '⚡', name: 'Use Commands' }, + daily_claims: { emoji: '🎁', name: 'Claim Daily Rewards' }, + level_ups: { emoji: '📈', name: 'Level Up' }, + xp_earned: { emoji: '✨', name: 'Earn XP' } +}; + +module.exports = { + data: new SlashCommandBuilder() + .setName('quests-manage') + .setDescription('Manage server quests (Admin only)') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(sub => + sub.setName('create') + .setDescription('Create a new quest') + .addStringOption(opt => + opt.setName('name') + .setDescription('Quest name') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('type') + .setDescription('Quest type') + .setRequired(true) + .addChoices( + { name: '☀️ Daily', value: 'daily' }, + { name: '📅 Weekly', value: 'weekly' }, + { name: '⭐ Special', value: 'special' } + ) + ) + .addStringOption(opt => + opt.setName('objective') + .setDescription('What users need to do') + .setRequired(true) + .addChoices( + { name: '💬 Send Messages', value: 'messages' }, + { name: '😄 Add Reactions', value: 'reactions' }, + { name: '🎙️ Voice Chat (minutes)', value: 'voice_minutes' }, + { name: '⚡ Use Commands', value: 'commands' }, + { name: '🎁 Claim Daily Rewards', value: 'daily_claims' }, + { name: '📈 Level Up', value: 'level_ups' }, + { name: '✨ Earn XP', value: 'xp_earned' } + ) + ) + .addIntegerOption(opt => + opt.setName('target') + .setDescription('Amount needed to complete') + .setRequired(true) + .setMinValue(1) + ) + .addIntegerOption(opt => + opt.setName('xp_reward') + .setDescription('XP reward for completion') + .setRequired(true) + .setMinValue(1) + ) + .addStringOption(opt => + opt.setName('description') + .setDescription('Quest description') + .setRequired(false) + ) + .addRoleOption(opt => + opt.setName('role_reward') + .setDescription('Role to grant on completion') + .setRequired(false) + ) + .addBooleanOption(opt => + opt.setName('repeatable') + .setDescription('Can users complete this quest multiple times?') + .setRequired(false) + ) + .addIntegerOption(opt => + opt.setName('duration_hours') + .setDescription('Quest duration in hours (auto-expires)') + .setRequired(false) + .setMinValue(1) + ) + ) + .addSubcommand(sub => + sub.setName('delete') + .setDescription('Delete a quest') + .addIntegerOption(opt => + opt.setName('quest_id') + .setDescription('The ID of the quest to delete') + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub.setName('edit') + .setDescription('Edit an existing quest') + .addIntegerOption(opt => + opt.setName('quest_id') + .setDescription('The ID of the quest to edit') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('name') + .setDescription('New quest name') + .setRequired(false) + ) + .addIntegerOption(opt => + opt.setName('xp_reward') + .setDescription('New XP reward') + .setRequired(false) + .setMinValue(1) + ) + .addIntegerOption(opt => + opt.setName('target') + .setDescription('New target value') + .setRequired(false) + .setMinValue(1) + ) + .addBooleanOption(opt => + opt.setName('active') + .setDescription('Enable or disable the quest') + .setRequired(false) + ) + ) + .addSubcommand(sub => + sub.setName('list') + .setDescription('List all quests') + ) + .addSubcommand(sub => + sub.setName('reset') + .setDescription('Reset quest progress for all users') + .addIntegerOption(opt => + opt.setName('quest_id') + .setDescription('The ID of the quest to reset (leave empty to reset all)') + .setRequired(false) + ) + .addStringOption(opt => + opt.setName('type') + .setDescription('Reset all quests of a type') + .setRequired(false) + .addChoices( + { name: '☀️ Daily', value: 'daily' }, + { name: '📅 Weekly', value: 'weekly' } + ) + ) + ) + .addSubcommand(sub => + sub.setName('stats') + .setDescription('View quest statistics') + ), + + async execute(interaction, supabase) { + if (!supabase) { + return interaction.reply({ content: 'Database not configured.', ephemeral: true }); + } + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'create': + return handleCreate(interaction, supabase); + case 'delete': + return handleDelete(interaction, supabase); + case 'edit': + return handleEdit(interaction, supabase); + case 'list': + return handleList(interaction, supabase); + case 'reset': + return handleReset(interaction, supabase); + case 'stats': + return handleStats(interaction, supabase); + } + } +}; + +async function handleCreate(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + const name = interaction.options.getString('name'); + const questType = interaction.options.getString('type'); + const objective = interaction.options.getString('objective'); + const target = interaction.options.getInteger('target'); + const xpReward = interaction.options.getInteger('xp_reward'); + const description = interaction.options.getString('description') || ''; + const roleReward = interaction.options.getRole('role_reward'); + const repeatable = interaction.options.getBoolean('repeatable') || false; + const durationHours = interaction.options.getInteger('duration_hours'); + + let expiresAt = null; + if (durationHours) { + expiresAt = new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString(); + } + + try { + const { data: newQuest, error } = await supabase + .from('quests') + .insert({ + guild_id: guildId, + name: name, + description: description, + quest_type: questType, + objective: objective, + target_value: target, + xp_reward: xpReward, + role_reward: roleReward?.id || null, + repeatable: repeatable, + starts_at: new Date().toISOString(), + expires_at: expiresAt, + active: true + }) + .select() + .single(); + + if (error) throw error; + + const typeInfo = QUEST_TYPES[questType] || { emoji: '📋', name: questType }; + const objInfo = OBJECTIVES[objective] || { emoji: '🎯', name: objective }; + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✅ Quest Created') + .addFields( + { name: 'Quest ID', value: `#${newQuest.id}`, inline: true }, + { name: 'Name', value: name, inline: true }, + { name: 'Type', value: `${typeInfo.emoji} ${typeInfo.name}`, inline: true }, + { name: 'Objective', value: `${objInfo.emoji} ${target}x ${objInfo.name}`, inline: true }, + { name: 'XP Reward', value: `${xpReward.toLocaleString()} XP`, inline: true }, + { name: 'Repeatable', value: repeatable ? 'Yes' : 'No', inline: true } + ); + + if (description) { + embed.addFields({ name: 'Description', value: description, inline: false }); + } + + if (roleReward) { + embed.addFields({ name: 'Role Reward', value: `<@&${roleReward.id}>`, inline: true }); + } + + if (expiresAt) { + embed.addFields({ + name: 'Expires', + value: ``, + inline: true + }); + } + + embed.setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Quest create error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to create quest.') + ] + }); + } +} + +async function handleDelete(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + const questId = interaction.options.getInteger('quest_id'); + + try { + const { data: quest } = await supabase + .from('quests') + .select('*') + .eq('id', questId) + .eq('guild_id', guildId) + .maybeSingle(); + + if (!quest) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('Quest not found.') + ] + }); + } + + await supabase + .from('quests') + .delete() + .eq('id', questId) + .eq('guild_id', guildId); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('🗑️ Quest Deleted') + .setDescription(`**${quest.name}** has been deleted.`) + .setTimestamp() + ] + }); + } catch (error) { + console.error('Quest delete error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to delete quest.') + ] + }); + } +} + +async function handleEdit(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + const questId = interaction.options.getInteger('quest_id'); + const newName = interaction.options.getString('name'); + const newXpReward = interaction.options.getInteger('xp_reward'); + const newTarget = interaction.options.getInteger('target'); + const active = interaction.options.getBoolean('active'); + + try { + const { data: quest } = await supabase + .from('quests') + .select('*') + .eq('id', questId) + .eq('guild_id', guildId) + .maybeSingle(); + + if (!quest) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('Quest not found.') + ] + }); + } + + const updates = { updated_at: new Date().toISOString() }; + const changes = []; + + if (newName !== null) { + updates.name = newName; + changes.push(`Name: ${quest.name} → ${newName}`); + } + if (newXpReward !== null) { + updates.xp_reward = newXpReward; + changes.push(`XP Reward: ${quest.xp_reward} → ${newXpReward}`); + } + if (newTarget !== null) { + updates.target_value = newTarget; + changes.push(`Target: ${quest.target_value} → ${newTarget}`); + } + if (active !== null) { + updates.active = active; + changes.push(`Active: ${quest.active} → ${active}`); + } + + if (changes.length === 0) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setDescription('No changes specified.') + ] + }); + } + + await supabase + .from('quests') + .update(updates) + .eq('id', questId); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✏️ Quest Updated') + .setDescription(`**${quest.name}** has been updated.`) + .addFields({ name: 'Changes', value: changes.join('\n') }) + .setTimestamp() + ] + }); + } catch (error) { + console.error('Quest edit error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to edit quest.') + ] + }); + } +} + +async function handleList(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + + try { + const { data: quests, error } = await supabase + .from('quests') + .select('*') + .eq('guild_id', guildId) + .order('quest_type') + .order('created_at', { ascending: false }); + + if (error || !quests || quests.length === 0) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setTitle('📋 Server Quests') + .setDescription('No quests created yet.\n\nUse `/quests-manage create` to add quests.') + ] + }); + } + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('📋 Server Quests (Admin View)') + .setDescription(`Total quests: **${quests.length}**`) + .setTimestamp(); + + const questLines = quests.map(quest => { + const status = quest.active ? '✅' : '❌'; + const typeInfo = QUEST_TYPES[quest.quest_type] || { emoji: '📋' }; + const objInfo = OBJECTIVES[quest.objective] || { emoji: '🎯', name: quest.objective }; + const expires = quest.expires_at && new Date(quest.expires_at) < new Date() ? ' ⏰' : ''; + return `${status} ${typeInfo.emoji} \`#${quest.id}\` **${quest.name}** - ${objInfo.emoji} ${quest.target_value}x → ${quest.xp_reward} XP${expires}`; + }); + + const chunks = []; + let current = ''; + for (const line of questLines) { + if ((current + '\n' + line).length > 1000) { + chunks.push(current); + current = line; + } else { + current = current ? current + '\n' + line : line; + } + } + if (current) chunks.push(current); + + for (let i = 0; i < Math.min(chunks.length, 5); i++) { + embed.addFields({ + name: i === 0 ? 'Quests' : '\u200b', + value: chunks[i], + inline: false + }); + } + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Quest list error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to list quests.') + ] + }); + } +} + +async function handleReset(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + const questId = interaction.options.getInteger('quest_id'); + const questType = interaction.options.getString('type'); + + try { + let deleteQuery = supabase + .from('user_quests') + .delete() + .eq('guild_id', guildId); + + let description = ''; + + if (questId) { + deleteQuery = deleteQuery.eq('quest_id', questId); + description = `Progress reset for quest #${questId}`; + } else if (questType) { + const { data: quests } = await supabase + .from('quests') + .select('id') + .eq('guild_id', guildId) + .eq('quest_type', questType); + + if (quests && quests.length > 0) { + deleteQuery = deleteQuery.in('quest_id', quests.map(q => q.id)); + description = `Progress reset for all ${QUEST_TYPES[questType]?.name || questType} quests`; + } else { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setDescription('No quests found for that type.') + ] + }); + } + } else { + description = 'Progress reset for all quests'; + } + + await deleteQuery; + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('🔄 Quest Progress Reset') + .setDescription(description) + .setTimestamp() + ] + }); + } catch (error) { + console.error('Quest reset error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to reset quest progress.') + ] + }); + } +} + +async function handleStats(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + + try { + const { data: quests } = await supabase + .from('quests') + .select('*') + .eq('guild_id', guildId); + + const { data: userQuests } = await supabase + .from('user_quests') + .select('*, quests(*)') + .eq('guild_id', guildId); + + const totalQuests = quests?.length || 0; + const activeQuests = quests?.filter(q => q.active).length || 0; + const totalCompletions = userQuests?.filter(uq => uq.completed).length || 0; + const totalClaimed = userQuests?.filter(uq => uq.claimed).length || 0; + const uniqueParticipants = new Set(userQuests?.map(uq => uq.user_id)).size; + const totalXPRewarded = userQuests + ?.filter(uq => uq.claimed && uq.quests) + .reduce((sum, uq) => sum + (uq.quests.xp_reward || 0), 0) || 0; + + const questCompletions = {}; + for (const uq of userQuests || []) { + if (uq.completed && uq.quests) { + const name = uq.quests.name; + questCompletions[name] = (questCompletions[name] || 0) + 1; + } + } + + const topQuests = Object.entries(questCompletions) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([name, count], i) => `${i + 1}. **${name}** (${count} completions)`) + .join('\n'); + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('📊 Quest Statistics') + .addFields( + { name: '📜 Total Quests', value: `${totalQuests} (${activeQuests} active)`, inline: true }, + { name: '✅ Total Completions', value: totalCompletions.toString(), inline: true }, + { name: '🎁 Total Claims', value: totalClaimed.toString(), inline: true }, + { name: '👥 Participants', value: uniqueParticipants.toString(), inline: true }, + { name: '💰 XP Rewarded', value: totalXPRewarded.toLocaleString(), inline: true } + ) + .setTimestamp(); + + if (topQuests) { + embed.addFields({ name: '🏆 Most Completed Quests', value: topQuests, inline: false }); + } + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Quest stats error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to get statistics.') + ] + }); + } +} diff --git a/aethex-bot/commands/quests.js b/aethex-bot/commands/quests.js new file mode 100644 index 0000000..ba8f186 --- /dev/null +++ b/aethex-bot/commands/quests.js @@ -0,0 +1,451 @@ +const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); + +const QUEST_TYPES = { + daily: { emoji: '☀️', name: 'Daily', color: 0xfbbf24 }, + weekly: { emoji: '📅', name: 'Weekly', color: 0x3b82f6 }, + special: { emoji: '⭐', name: 'Special', color: 0xa855f7 } +}; + +const OBJECTIVES = { + messages: { emoji: '💬', name: 'Send Messages' }, + reactions: { emoji: '😄', name: 'Add Reactions' }, + voice_minutes: { emoji: '🎙️', name: 'Voice Chat (minutes)' }, + commands: { emoji: '⚡', name: 'Use Commands' }, + daily_claims: { emoji: '🎁', name: 'Claim Daily Rewards' }, + level_ups: { emoji: '📈', name: 'Level Up' }, + xp_earned: { emoji: '✨', name: 'Earn XP' } +}; + +module.exports = { + data: new SlashCommandBuilder() + .setName('quests') + .setDescription('View and manage your quests') + .addSubcommand(sub => + sub.setName('view') + .setDescription('View available quests') + .addStringOption(opt => + opt.setName('type') + .setDescription('Filter by quest type') + .setRequired(false) + .addChoices( + { name: '☀️ Daily', value: 'daily' }, + { name: '📅 Weekly', value: 'weekly' }, + { name: '⭐ Special', value: 'special' } + ) + ) + ) + .addSubcommand(sub => + sub.setName('progress') + .setDescription('View your quest progress') + ) + .addSubcommand(sub => + sub.setName('claim') + .setDescription('Claim rewards for completed quests') + .addIntegerOption(opt => + opt.setName('quest_id') + .setDescription('The ID of the quest to claim (leave empty to claim all)') + .setRequired(false) + ) + ), + + async execute(interaction, supabase) { + if (!supabase) { + return interaction.reply({ content: 'Database not configured.', ephemeral: true }); + } + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'view': + return handleView(interaction, supabase); + case 'progress': + return handleProgress(interaction, supabase); + case 'claim': + return handleClaim(interaction, supabase); + } + } +}; + +async function getUserLink(supabase, discordId) { + const { data } = await supabase + .from('discord_links') + .select('user_id') + .eq('discord_id', discordId) + .maybeSingle(); + return data; +} + +async function handleView(interaction, supabase) { + await interaction.deferReply(); + + const guildId = interaction.guildId; + const questType = interaction.options.getString('type'); + const now = new Date().toISOString(); + + let query = supabase + .from('quests') + .select('*') + .eq('guild_id', guildId) + .eq('active', true) + .or(`expires_at.is.null,expires_at.gt.${now}`) + .or(`starts_at.is.null,starts_at.lte.${now}`) + .order('quest_type') + .order('xp_reward', { ascending: false }); + + if (questType) { + query = query.eq('quest_type', questType); + } + + const { data: quests, error } = await query; + + if (error || !quests || quests.length === 0) { + const embed = new EmbedBuilder() + .setColor(0xfbbf24) + .setTitle('📜 Quests') + .setDescription(questType + ? `No active ${QUEST_TYPES[questType]?.name || questType} quests available.` + : 'No active quests available right now.\n\nCheck back later or ask a server admin to add quests!') + .setTimestamp(); + + return interaction.editReply({ embeds: [embed] }); + } + + const link = await getUserLink(supabase, interaction.user.id); + let userQuests = []; + if (link) { + const { data } = await supabase + .from('user_quests') + .select('*') + .eq('user_id', link.user_id) + .eq('guild_id', guildId); + userQuests = data || []; + } + + const userQuestMap = {}; + for (const uq of userQuests) { + userQuestMap[uq.quest_id] = uq; + } + + const groupedQuests = {}; + for (const quest of quests) { + const type = quest.quest_type || 'special'; + if (!groupedQuests[type]) groupedQuests[type] = []; + groupedQuests[type].push(quest); + } + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('📜 Available Quests') + .setDescription(link + ? 'Complete quests to earn XP rewards!' + : 'Link your account with `/verify` to track quest progress!') + .setThumbnail(interaction.guild.iconURL({ size: 128 })) + .setFooter({ text: `Use /quests progress to see your progress • ${quests.length} quests available` }) + .setTimestamp(); + + for (const [type, typeQuests] of Object.entries(groupedQuests)) { + const typeInfo = QUEST_TYPES[type] || { emoji: '📋', name: type }; + const questList = typeQuests.slice(0, 5).map(quest => { + const objInfo = OBJECTIVES[quest.objective] || { emoji: '🎯', name: quest.objective }; + const userQuest = userQuestMap[quest.id]; + + let status = ''; + if (userQuest) { + if (userQuest.claimed) { + status = ' ✅ Claimed'; + } else if (userQuest.completed) { + status = ' 🎁 Ready to claim!'; + } else { + const percent = Math.min(100, Math.floor((userQuest.progress / quest.target_value) * 100)); + status = ` [${userQuest.progress}/${quest.target_value}] ${percent}%`; + } + } + + const expires = quest.expires_at + ? ` (ends )` + : ''; + + return `\`#${quest.id}\` **${quest.name}**${status}\n${objInfo.emoji} ${quest.target_value}x ${objInfo.name} → **${quest.xp_reward.toLocaleString()} XP**${expires}`; + }).join('\n\n'); + + const moreQuests = typeQuests.length > 5 ? `\n\n*...and ${typeQuests.length - 5} more*` : ''; + + embed.addFields({ + name: `${typeInfo.emoji} ${typeInfo.name} Quests`, + value: questList + moreQuests, + inline: false + }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleProgress(interaction, supabase) { + await interaction.deferReply(); + + const guildId = interaction.guildId; + const link = await getUserLink(supabase, interaction.user.id); + + if (!link) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('You need to link your account first! Use `/verify` to get started.') + ] + }); + } + + const now = new Date().toISOString(); + + const { data: quests } = await supabase + .from('quests') + .select('*') + .eq('guild_id', guildId) + .eq('active', true) + .or(`expires_at.is.null,expires_at.gt.${now}`) + .or(`starts_at.is.null,starts_at.lte.${now}`); + + if (!quests || quests.length === 0) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setTitle('📊 Quest Progress') + .setDescription('No active quests to track.') + ] + }); + } + + const questIds = quests.map(q => q.id); + const { data: userQuests } = await supabase + .from('user_quests') + .select('*') + .eq('user_id', link.user_id) + .eq('guild_id', guildId) + .in('quest_id', questIds); + + const userQuestMap = {}; + for (const uq of userQuests || []) { + userQuestMap[uq.quest_id] = uq; + } + + const inProgress = []; + const completed = []; + const notStarted = []; + + for (const quest of quests) { + const uq = userQuestMap[quest.id]; + if (!uq) { + notStarted.push(quest); + } else if (uq.claimed) { + continue; + } else if (uq.completed) { + completed.push({ quest, userQuest: uq }); + } else { + inProgress.push({ quest, userQuest: uq }); + } + } + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('📊 Your Quest Progress') + .setThumbnail(interaction.user.displayAvatarURL({ size: 128 })) + .setTimestamp(); + + if (completed.length > 0) { + const completedList = completed.slice(0, 5).map(({ quest }) => { + const typeInfo = QUEST_TYPES[quest.quest_type] || { emoji: '📋' }; + return `${typeInfo.emoji} \`#${quest.id}\` **${quest.name}** - ${quest.xp_reward.toLocaleString()} XP 🎁`; + }).join('\n'); + embed.addFields({ + name: '🎉 Ready to Claim!', + value: completedList, + inline: false + }); + } + + if (inProgress.length > 0) { + const progressList = inProgress.slice(0, 5).map(({ quest, userQuest }) => { + const typeInfo = QUEST_TYPES[quest.quest_type] || { emoji: '📋' }; + const percent = Math.min(100, Math.floor((userQuest.progress / quest.target_value) * 100)); + const progressBar = createProgressBar(percent); + return `${typeInfo.emoji} \`#${quest.id}\` **${quest.name}**\n${progressBar} ${userQuest.progress}/${quest.target_value} (${percent}%)`; + }).join('\n\n'); + embed.addFields({ + name: '🔄 In Progress', + value: progressList, + inline: false + }); + } + + if (notStarted.length > 0 && (completed.length + inProgress.length) < 5) { + const notStartedList = notStarted.slice(0, 3).map(quest => { + const typeInfo = QUEST_TYPES[quest.quest_type] || { emoji: '📋' }; + const objInfo = OBJECTIVES[quest.objective] || { emoji: '🎯', name: quest.objective }; + return `${typeInfo.emoji} \`#${quest.id}\` **${quest.name}** - ${quest.target_value}x ${objInfo.name}`; + }).join('\n'); + embed.addFields({ + name: '📝 Not Started', + value: notStartedList + (notStarted.length > 3 ? `\n*...and ${notStarted.length - 3} more*` : ''), + inline: false + }); + } + + if (completed.length === 0 && inProgress.length === 0 && notStarted.length === 0) { + embed.setDescription('You\'ve completed all available quests! Check back later for more.'); + } else { + const totalXP = completed.reduce((sum, { quest }) => sum + quest.xp_reward, 0); + if (totalXP > 0) { + embed.setDescription(`You have **${totalXP.toLocaleString()} XP** waiting to be claimed! Use \`/quests claim\``); + } + } + + embed.setFooter({ text: 'Use /quests claim to collect your rewards!' }); + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleClaim(interaction, supabase) { + await interaction.deferReply(); + + const guildId = interaction.guildId; + const questId = interaction.options.getInteger('quest_id'); + + const link = await getUserLink(supabase, interaction.user.id); + if (!link) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('You need to link your account first! Use `/verify` to get started.') + ] + }); + } + + let query = supabase + .from('user_quests') + .select('*, quests(*)') + .eq('user_id', link.user_id) + .eq('guild_id', guildId) + .eq('completed', true) + .eq('claimed', false); + + if (questId) { + query = query.eq('quest_id', questId); + } + + const { data: claimable, error } = await query; + + if (error || !claimable || claimable.length === 0) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setTitle('No Rewards to Claim') + .setDescription(questId + ? 'This quest is not completed or already claimed.' + : 'You don\'t have any completed quests to claim.\n\nComplete quests to earn rewards!') + ] + }); + } + + let totalXP = 0; + const claimedQuests = []; + const rolesGranted = []; + const now = new Date().toISOString(); + + for (const uq of claimable) { + const quest = uq.quests; + if (!quest) continue; + + totalXP += quest.xp_reward; + claimedQuests.push(quest.name); + + await supabase + .from('user_quests') + .update({ claimed: true, claimed_at: now }) + .eq('id', uq.id); + + if (quest.role_reward) { + try { + await interaction.member.roles.add(quest.role_reward); + rolesGranted.push(quest.role_reward); + } catch (e) { + console.error('Failed to add quest role reward:', e.message); + } + } + } + + if (totalXP > 0) { + const { data: profile } = await supabase + .from('user_profiles') + .select('xp, total_xp_earned') + .eq('id', link.user_id) + .maybeSingle(); + + const currentXP = profile?.xp || 0; + const currentTotal = profile?.total_xp_earned || currentXP; + + await supabase + .from('user_profiles') + .update({ + xp: currentXP + totalXP, + total_xp_earned: currentTotal + totalXP, + updated_at: now + }) + .eq('id', link.user_id); + + const { data: balance } = await supabase + .from('user_balance') + .select('*') + .eq('user_id', link.user_id) + .eq('guild_id', guildId) + .maybeSingle(); + + if (balance) { + await supabase + .from('user_balance') + .update({ + balance: balance.balance + totalXP, + total_earned: balance.total_earned + totalXP, + updated_at: now + }) + .eq('user_id', link.user_id) + .eq('guild_id', guildId); + } + } + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('🎉 Rewards Claimed!') + .setDescription(`You claimed rewards for **${claimable.length}** quest${claimable.length > 1 ? 's' : ''}!`) + .addFields( + { name: '💰 XP Earned', value: `**+${totalXP.toLocaleString()} XP**`, inline: true } + ) + .setTimestamp(); + + if (claimedQuests.length <= 5) { + embed.addFields({ + name: '📜 Quests Completed', + value: claimedQuests.map(n => `• ${n}`).join('\n'), + inline: false + }); + } + + if (rolesGranted.length > 0) { + embed.addFields({ + name: '👑 Roles Granted', + value: rolesGranted.map(r => `<@&${r}>`).join(', '), + inline: false + }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +function createProgressBar(percent) { + const filled = Math.floor(percent / 10); + const empty = 10 - filled; + return '█'.repeat(filled) + '░'.repeat(empty); +} diff --git a/aethex-bot/docs/MANUAL.md b/aethex-bot/docs/MANUAL.md index 70f300b..938b61a 100644 --- a/aethex-bot/docs/MANUAL.md +++ b/aethex-bot/docs/MANUAL.md @@ -371,6 +371,60 @@ Spend your hard-earned XP on cosmetics, perks, and exclusive items! - For roles: specify `grant_role` to give users a role on purchase - For boosters: set `booster_multiplier` and `booster_hours` +### Quest System + +Complete quests to earn bonus XP! Quests provide rotating objectives that keep engagement fresh. + +**User Commands:** +``` +/quests view [type] # View available quests +/quests progress # See your current progress +/quests claim [quest_id] # Claim rewards for completed quests +``` + +**Quest Types:** +| Type | Description | +|------|-------------| +| Daily | Reset every day, quick objectives | +| Weekly | Larger goals, higher rewards | +| Special | Limited-time events and challenges | + +**Quest Objectives:** +| Objective | What to Do | +|-----------|------------| +| messages | Send messages in the server | +| reactions | Add reactions to messages | +| voice_minutes | Spend time in voice channels | +| commands | Use bot commands | +| daily_claims | Claim daily rewards | +| level_ups | Level up your account | +| xp_earned | Earn XP from any source | + +**How Quests Work:** +1. View available quests with `/quests view` +2. Progress is tracked automatically as you participate +3. Check your progress with `/quests progress` +4. Once completed, claim your XP with `/quests claim` +5. Some quests may also grant roles as rewards! + +**Admin Commands (quests-manage):** +``` +/quests-manage create [name] [type] [objective] [target] [xp_reward] +/quests-manage edit [options] +/quests-manage delete +/quests-manage list +/quests-manage reset [quest_id] [type] +/quests-manage stats +``` + +**Creating Quests:** +- `name`: Quest display name +- `type`: daily, weekly, or special +- `objective`: What users need to do +- `target`: Amount needed to complete +- `xp_reward`: XP given on completion +- Optional: description, role_reward, duration_hours, repeatable + --- ## Moderation Tools @@ -684,6 +738,12 @@ Opens a form to set: | `/shop browse/buy/inventory/equip/balance` | XP shop for cosmetics and perks | | `/shop-manage add/edit/remove/list/stats` | Admin shop management | +### Quest Commands (2) +| Command | Description | +|---------|-------------| +| `/quests view/progress/claim` | View and complete quests for XP | +| `/quests-manage create/edit/delete/list/reset/stats` | Admin quest management | + ### Moderation Commands (5) | Command | Description | |---------|-------------| diff --git a/aethex-bot/listeners/reactionXp.js b/aethex-bot/listeners/reactionXp.js index bb4edd0..3302aa7 100644 --- a/aethex-bot/listeners/reactionXp.js +++ b/aethex-bot/listeners/reactionXp.js @@ -1,4 +1,4 @@ -const { checkMilestoneRoles, calculateLevel, updateUserStats, getUserStats, updatePeriodicXp } = require('./xpTracker'); +const { checkMilestoneRoles, calculateLevel, updateUserStats, getUserStats, updatePeriodicXp, updateQuestProgress } = require('./xpTracker'); const { checkAchievements } = require('../commands/achievements'); const reactionCooldowns = new Map(); @@ -153,6 +153,12 @@ async function grantXp(supabase, discordUserId, xpAmount, client, member, config stats.dailyStreak = profile.daily_streak || 0; await checkAchievements(link.user_id, member, stats, supabase, guildId, client); + + // Track quest progress for reactions + if (reactionType === 'giver') { + await updateQuestProgress(supabase, link.user_id, guildId, 'reactions', 1); + } + await updateQuestProgress(supabase, link.user_id, guildId, 'xp_earned', finalXp); } } diff --git a/aethex-bot/listeners/voiceXp.js b/aethex-bot/listeners/voiceXp.js index 0178a5e..a3f5536 100644 --- a/aethex-bot/listeners/voiceXp.js +++ b/aethex-bot/listeners/voiceXp.js @@ -1,4 +1,4 @@ -const { checkMilestoneRoles, calculateLevel, updateUserStats, getUserStats, updatePeriodicXp } = require('./xpTracker'); +const { checkMilestoneRoles, calculateLevel, updateUserStats, getUserStats, updatePeriodicXp, updateQuestProgress } = require('./xpTracker'); const { checkAchievements } = require('../commands/achievements'); const voiceSessions = new Map(); @@ -233,6 +233,14 @@ async function grantVoiceXp(supabase, client, guildId, userId, config, member, m stats.dailyStreak = profile.daily_streak || 0; await checkAchievements(link.user_id, member, stats, supabase, guildId, client); + + // Track quest progress for voice chat + await updateQuestProgress(supabase, link.user_id, guildId, 'voice_minutes', minutes); + await updateQuestProgress(supabase, link.user_id, guildId, 'xp_earned', xpGain); + + if (newLevel > oldLevel) { + await updateQuestProgress(supabase, link.user_id, guildId, 'level_ups', 1); + } } } catch (error) { diff --git a/aethex-bot/listeners/xpTracker.js b/aethex-bot/listeners/xpTracker.js index 3561fb4..1fa2c3f 100644 --- a/aethex-bot/listeners/xpTracker.js +++ b/aethex-bot/listeners/xpTracker.js @@ -5,6 +5,78 @@ const xpCooldowns = new Map(); const xpConfigCache = new Map(); const CACHE_TTL = 60000; // 1 minute cache +async function updateQuestProgress(supabase, userId, guildId, objective, amount = 1) { + try { + const now = new Date().toISOString(); + + const { data: allQuests } = await supabase + .from('quests') + .select('*') + .eq('guild_id', guildId) + .eq('objective', objective) + .eq('active', true); + + if (!allQuests || allQuests.length === 0) return; + + const activeQuests = allQuests.filter(quest => { + const notExpired = !quest.expires_at || new Date(quest.expires_at) > new Date(now); + const hasStarted = !quest.starts_at || new Date(quest.starts_at) <= new Date(now); + return notExpired && hasStarted; + }); + + if (!activeQuests || activeQuests.length === 0) return; + + for (const quest of activeQuests) { + const { data: userQuest } = await supabase + .from('user_quests') + .select('*') + .eq('user_id', userId) + .eq('guild_id', guildId) + .eq('quest_id', quest.id) + .maybeSingle(); + + if (userQuest) { + if (userQuest.claimed && !quest.repeatable) continue; + if (userQuest.completed && !quest.repeatable) continue; + + const newProgress = (userQuest.progress || 0) + amount; + const completed = newProgress >= quest.target_value; + + const updates = { + progress: Math.min(newProgress, quest.target_value) + }; + + if (completed && !userQuest.completed) { + updates.completed = true; + updates.completed_at = now; + } + + await supabase + .from('user_quests') + .update(updates) + .eq('id', userQuest.id); + } else { + const progress = Math.min(amount, quest.target_value); + const completed = progress >= quest.target_value; + + await supabase + .from('user_quests') + .insert({ + user_id: userId, + guild_id: guildId, + quest_id: quest.id, + progress: progress, + completed: completed, + completed_at: completed ? now : null, + started_at: now + }); + } + } + } catch (e) { + // Silently ignore quest tracking errors + } +} + module.exports = { name: 'messageCreate', @@ -142,6 +214,15 @@ module.exports = { // Check for achievements await checkAchievements(link.user_id, message.member, stats, supabase, guildId, client); + // Track quest progress for messages and XP earned + await updateQuestProgress(supabase, link.user_id, guildId, 'messages', 1); + await updateQuestProgress(supabase, link.user_id, guildId, 'xp_earned', xpGain); + + // Track level-up quest progress + if (newLevel > oldLevel) { + await updateQuestProgress(supabase, link.user_id, guildId, 'level_ups', 1); + } + } catch (error) { console.error('XP tracking error:', error.message); } @@ -462,3 +543,4 @@ module.exports.checkMilestoneRoles = checkMilestoneRoles; module.exports.updateUserStats = updateUserStats; module.exports.getUserStats = getUserStats; module.exports.updatePeriodicXp = updatePeriodicXp; +module.exports.updateQuestProgress = updateQuestProgress;