diff --git a/bot/commands/quest/quests.ts b/bot/commands/quest/quests.ts index fab6621..32262e5 100644 --- a/bot/commands/quest/quests.ts +++ b/bot/commands/quest/quests.ts @@ -1,25 +1,74 @@ import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, MessageFlags } from "discord.js"; +import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js"; import { questService } from "@shared/modules/quest/quest.service"; -import { createWarningEmbed } from "@lib/embeds"; -import { getQuestListEmbed } from "@/modules/quest/quest.view"; +import { createSuccessEmbed, createWarningEmbed } from "@lib/embeds"; +import { getQuestListEmbed, getAvailableQuestsEmbed, getQuestActionRows } from "@/modules/quest/quest.view"; export const quests = createCommand({ data: new SlashCommandBuilder() .setName("quests") - .setDescription("View your active quests"), + .setDescription("View your active and available quests"), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const userQuests = await questService.getUserQuests(interaction.user.id); + const userId = interaction.user.id; - if (!userQuests || userQuests.length === 0) { - await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] }); - return; - } + const updateView = async (viewType: 'active' | 'available') => { + const userQuests = await questService.getUserQuests(userId); + const availableQuests = await questService.getAvailableQuests(userId); - const embed = getQuestListEmbed(userQuests); + const embed = viewType === 'active' + ? getQuestListEmbed(userQuests) + : getAvailableQuestsEmbed(availableQuests); + + const components = getQuestActionRows(viewType, availableQuests); - await interaction.editReply({ embeds: [embed] }); + await interaction.editReply({ + embeds: [embed], + components: components + }); + }; + + // Initial view + await updateView('active'); + + const collector = response.createMessageComponentCollector({ + time: 60000, + componentType: undefined // Allow both buttons and select menu + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) return; + + try { + if (i.customId === "quest_view_active") { + await i.deferUpdate(); + await updateView('active'); + } else if (i.customId === "quest_view_available") { + await i.deferUpdate(); + await updateView('available'); + } else if (i.customId === "quest_accept_select") { + const questId = parseInt((i as any).values[0]); + await questService.assignQuest(userId, questId); + + await i.reply({ + embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")], + flags: MessageFlags.Ephemeral + }); + + await updateView('active'); + } + } catch (error) { + console.error("Quest interaction error:", error); + await i.followUp({ + content: "Something went wrong while processing your quest interaction.", + flags: MessageFlags.Ephemeral + }); + } + }); + + collector.on('end', () => { + interaction.editReply({ components: [] }).catch(() => {}); + }); } }); diff --git a/bot/modules/quest/quest.view.ts b/bot/modules/quest/quest.view.ts index e090c8e..6343dc4 100644 --- a/bot/modules/quest/quest.view.ts +++ b/bot/modules/quest/quest.view.ts @@ -1,4 +1,4 @@ -import { EmbedBuilder } from "discord.js"; +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js"; /** * Quest entry with quest details and progress @@ -7,12 +7,26 @@ interface QuestEntry { progress: number | null; completedAt: Date | null; quest: { + id: number; name: string; description: string | null; + triggerEvent: string; + requirements: any; rewards: any; }; } +/** + * Available quest interface + */ +interface AvailableQuest { + id: number; + name: string; + description: string | null; + rewards: any; + requirements: any; +} + /** * Formats quest rewards object into a human-readable string */ @@ -30,25 +44,119 @@ function getQuestStatus(completedAt: Date | null): string { return completedAt ? "βœ… Completed" : "πŸ“ In Progress"; } +/** + * Renders a simple progress bar + */ +function renderProgressBar(current: number, total: number, size: number = 10): string { + const percentage = Math.min(current / total, 1); + const progress = Math.round(size * percentage); + const empty = size - progress; + + const progressText = "β–°".repeat(progress); + const emptyText = "β–±".repeat(empty); + + return `${progressText}${emptyText} ${Math.round(percentage * 100)}% (${current}/${total})`; +} + /** * Creates an embed displaying a user's quest log */ export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder { const embed = new EmbedBuilder() .setTitle("πŸ“œ Quest Log") + .setDescription("Your active and completed quests.") .setColor(0x3498db); // Blue + if (userQuests.length === 0) { + embed.setDescription("You have no active quests. Check available quests!"); + } + userQuests.forEach(entry => { const status = getQuestStatus(entry.completedAt); const rewards = entry.quest.rewards as { xp?: number, balance?: number }; const rewardsText = formatQuestRewards(rewards); + + const requirements = entry.quest.requirements as { target?: number }; + const target = requirements?.target || 1; + const progress = entry.progress || 0; + + const progressBar = entry.completedAt ? "βœ… Fully completed" : renderProgressBar(progress, target); embed.addFields({ name: `${entry.quest.name} (${status})`, - value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`, + value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${progressBar}`, inline: false }); }); return embed; } + +/** + * Creates an embed for available quests + */ +export function getAvailableQuestsEmbed(availableQuests: AvailableQuest[]): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("πŸ—ΊοΈ Available Quests") + .setDescription("Quests you can accept right now.") + .setColor(0x2ecc71); // Green + + if (availableQuests.length === 0) { + embed.setDescription("There are no new quests available for you at the moment."); + } + + availableQuests.forEach(quest => { + const rewards = quest.rewards as { xp?: number, balance?: number }; + const rewardsText = formatQuestRewards(rewards); + + const requirements = quest.requirements as { target?: number }; + const target = requirements?.target || 1; + + embed.addFields({ + name: quest.name, + value: `${quest.description}\n**Goal:** Reach ${target} for this activity.\n**Rewards:** ${rewardsText}`, + inline: false + }); + }); + + return embed; +} + +/** + * Returns action rows for the quest view + */ +export function getQuestActionRows(viewType: 'active' | 'available', availableQuests: AvailableQuest[] = []): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + + const navRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("quest_view_active") + .setLabel("Active Quests") + .setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setDisabled(viewType === 'active'), + new ButtonBuilder() + .setCustomId("quest_view_available") + .setLabel("Available Quests") + .setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setDisabled(viewType === 'available') + ); + rows.push(navRow); + + if (viewType === 'available' && availableQuests.length > 0) { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId("quest_accept_select") + .setPlaceholder("Select a quest to accept") + .addOptions( + availableQuests.slice(0, 25).map(q => + new StringSelectMenuOptionBuilder() + .setLabel(q.name) + .setDescription(q.description?.substring(0, 100) || "") + .setValue(q.id.toString()) + ) + ); + + rows.push(new ActionRowBuilder().addComponents(selectMenu)); + } + + return rows; +} diff --git a/shared/modules/quest/quest.service.test.ts b/shared/modules/quest/quest.service.test.ts index 220523d..96cadc6 100644 --- a/shared/modules/quest/quest.service.test.ts +++ b/shared/modules/quest/quest.service.test.ts @@ -33,6 +33,7 @@ mock.module("@shared/db/DrizzleClient", () => { const createMockTx = () => ({ query: { userQuests: { findFirst: mockFindFirst, findMany: mockFindMany }, + quests: { findMany: mockFindMany }, }, insert: mockInsert, update: mockUpdate, @@ -149,6 +150,31 @@ describe("questService", () => { }); }); + describe("getAvailableQuests", () => { + it("should return quests not yet accepted by user", async () => { + // First call to findMany (userQuests) returns accepted quest IDs + // Second call to findMany (quests) returns available quests + mockFindMany + .mockResolvedValueOnce([{ questId: 1 }]) // userQuests + .mockResolvedValueOnce([{ id: 2, name: "New Quest" }]); // quests + + const result = await questService.getAvailableQuests("1"); + + expect(result).toEqual([{ id: 2, name: "New Quest" }] as any); + expect(mockFindMany).toHaveBeenCalledTimes(2); + }); + + it("should return all quests if user has no assigned quests", async () => { + mockFindMany + .mockResolvedValueOnce([]) // userQuests + .mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); // quests + + const result = await questService.getAvailableQuests("1"); + + expect(result).toEqual([{ id: 1 }, { id: 2 }] as any); + }); + }); + describe("handleEvent", () => { it("should progress a quest with sub-events", async () => { const mockUserQuest = { diff --git a/shared/modules/quest/quest.service.ts b/shared/modules/quest/quest.service.ts index 1bf8ed6..4cc187f 100644 --- a/shared/modules/quest/quest.service.ts +++ b/shared/modules/quest/quest.service.ts @@ -118,5 +118,20 @@ export const questService = { quest: true, } }); + }, + + getAvailableQuests: async (userId: string) => { + const userQuestIds = (await DrizzleClient.query.userQuests.findMany({ + where: eq(userQuests.userId, BigInt(userId)), + columns: { + questId: true + } + })).map(uq => uq.questId); + + return await DrizzleClient.query.quests.findMany({ + where: (quests, { notInArray }) => userQuestIds.length > 0 + ? notInArray(quests.id, userQuestIds) + : undefined + }); } };