Implement a new quest system with commands for users and administrators, including tracking progress for various objectives and awarding XP. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 3d62eaac-3ee6-4585-b52c-552b348253ee Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/zRLxuQq Replit-Helium-Checkpoint-Created: true
451 lines
14 KiB
JavaScript
451 lines
14 KiB
JavaScript
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 <t:${Math.floor(new Date(quest.expires_at).getTime() / 1000)}:R>)`
|
|
: '';
|
|
|
|
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);
|
|
}
|