feat: Implement interactive quest command allowing users to view active/available quests and accept new ones.
This commit is contained in:
@@ -1,25 +1,74 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
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 { questService } from "@shared/modules/quest/quest.service";
|
||||||
import { createWarningEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createWarningEmbed } from "@lib/embeds";
|
||||||
import { getQuestListEmbed } from "@/modules/quest/quest.view";
|
import { getQuestListEmbed, getAvailableQuestsEmbed, getQuestActionRows } from "@/modules/quest/quest.view";
|
||||||
|
|
||||||
export const quests = createCommand({
|
export const quests = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("quests")
|
.setName("quests")
|
||||||
.setDescription("View your active quests"),
|
.setDescription("View your active and available quests"),
|
||||||
execute: async (interaction) => {
|
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) {
|
const updateView = async (viewType: 'active' | 'available') => {
|
||||||
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
|
const userQuests = await questService.getUserQuests(userId);
|
||||||
return;
|
const availableQuests = await questService.getAvailableQuests(userId);
|
||||||
|
|
||||||
|
const embed = viewType === 'active'
|
||||||
|
? getQuestListEmbed(userQuests)
|
||||||
|
: getAvailableQuestsEmbed(availableQuests);
|
||||||
|
|
||||||
|
const components = getQuestActionRows(viewType, availableQuests);
|
||||||
|
|
||||||
|
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) {
|
||||||
const embed = getQuestListEmbed(userQuests);
|
console.error("Quest interaction error:", error);
|
||||||
|
await i.followUp({
|
||||||
await interaction.editReply({ embeds: [embed] });
|
content: "Something went wrong while processing your quest interaction.",
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', () => {
|
||||||
|
interaction.editReply({ components: [] }).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
* Quest entry with quest details and progress
|
||||||
@@ -7,10 +7,24 @@ interface QuestEntry {
|
|||||||
progress: number | null;
|
progress: number | null;
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
quest: {
|
quest: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
triggerEvent: string;
|
||||||
|
requirements: any;
|
||||||
|
rewards: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available quest interface
|
||||||
|
*/
|
||||||
|
interface AvailableQuest {
|
||||||
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
rewards: any;
|
rewards: any;
|
||||||
};
|
requirements: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,25 +44,119 @@ function getQuestStatus(completedAt: Date | null): string {
|
|||||||
return completedAt ? "✅ Completed" : "📝 In Progress";
|
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
|
* Creates an embed displaying a user's quest log
|
||||||
*/
|
*/
|
||||||
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle("📜 Quest Log")
|
.setTitle("📜 Quest Log")
|
||||||
|
.setDescription("Your active and completed quests.")
|
||||||
.setColor(0x3498db); // Blue
|
.setColor(0x3498db); // Blue
|
||||||
|
|
||||||
|
if (userQuests.length === 0) {
|
||||||
|
embed.setDescription("You have no active quests. Check available quests!");
|
||||||
|
}
|
||||||
|
|
||||||
userQuests.forEach(entry => {
|
userQuests.forEach(entry => {
|
||||||
const status = getQuestStatus(entry.completedAt);
|
const status = getQuestStatus(entry.completedAt);
|
||||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||||
const rewardsText = formatQuestRewards(rewards);
|
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({
|
embed.addFields({
|
||||||
name: `${entry.quest.name} (${status})`,
|
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
|
inline: false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return embed;
|
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<any>[] {
|
||||||
|
const rows: ActionRowBuilder<any>[] = [];
|
||||||
|
|
||||||
|
const navRow = new ActionRowBuilder<ButtonBuilder>().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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ mock.module("@shared/db/DrizzleClient", () => {
|
|||||||
const createMockTx = () => ({
|
const createMockTx = () => ({
|
||||||
query: {
|
query: {
|
||||||
userQuests: { findFirst: mockFindFirst, findMany: mockFindMany },
|
userQuests: { findFirst: mockFindFirst, findMany: mockFindMany },
|
||||||
|
quests: { findMany: mockFindMany },
|
||||||
},
|
},
|
||||||
insert: mockInsert,
|
insert: mockInsert,
|
||||||
update: mockUpdate,
|
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", () => {
|
describe("handleEvent", () => {
|
||||||
it("should progress a quest with sub-events", async () => {
|
it("should progress a quest with sub-events", async () => {
|
||||||
const mockUserQuest = {
|
const mockUserQuest = {
|
||||||
|
|||||||
@@ -118,5 +118,20 @@ export const questService = {
|
|||||||
quest: true,
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user