Add quest system for users to complete objectives and earn rewards

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
This commit is contained in:
sirpiglr 2025-12-08 22:13:28 +00:00
parent 98aedc46e7
commit 7c10440040
8 changed files with 1227 additions and 4 deletions

View file

@ -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')

View file

@ -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.' });

View file

@ -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: `<t:${Math.floor(new Date(expiresAt).getTime() / 1000)}:R>`,
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.')
]
});
}
}

View file

@ -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 <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);
}

View file

@ -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 <quest_id> [options]
/quests-manage delete <quest_id>
/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 |
|---------|-------------|

View file

@ -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);
}
}

View file

@ -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) {

View file

@ -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;