From 2ce768013d53af54c06aa0f73f04995cfc1a3e1f Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 18 Dec 2025 19:16:43 +0100 Subject: [PATCH] feat: implement interactive item creation wizard via new /createitem command --- src/commands/admin/create_item.ts | 14 ++ src/events/interactionCreate.ts | 4 + src/modules/admin/item_wizard.ts | 329 ++++++++++++++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 src/commands/admin/create_item.ts create mode 100644 src/modules/admin/item_wizard.ts diff --git a/src/commands/admin/create_item.ts b/src/commands/admin/create_item.ts new file mode 100644 index 0000000..d1a6f0a --- /dev/null +++ b/src/commands/admin/create_item.ts @@ -0,0 +1,14 @@ +import { createCommand } from "@/lib/utils"; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js"; +import { renderWizard } from "@/modules/admin/item_wizard"; + +export const createItem = createCommand({ + data: new SlashCommandBuilder() + .setName("createitem") + .setDescription("Create a new item using the interactive wizard") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction) => { + const payload = renderWizard(interaction.user.id); + await interaction.reply({ ...payload, flags: MessageFlags.Ephemeral }); + } +}); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index f924a4a..7750365 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -21,6 +21,10 @@ const event: Event = { await import("@/modules/economy/lootdrop.interaction").then(m => m.handleLootdropInteraction(interaction)); return; } + if (interaction.customId.startsWith("createitem_")) { + await import("@/modules/admin/item_wizard").then(m => m.handleItemWizardInteraction(interaction)); + return; + } } if (interaction.isAutocomplete()) { diff --git a/src/modules/admin/item_wizard.ts b/src/modules/admin/item_wizard.ts new file mode 100644 index 0000000..fee6b87 --- /dev/null +++ b/src/modules/admin/item_wizard.ts @@ -0,0 +1,329 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + ModalBuilder, + StringSelectMenuBuilder, + TextInputBuilder, + TextInputStyle, + type Interaction, + type MessageActionRowComponentBuilder +} from "discord.js"; +import { items } from "@/db/schema"; +import { DrizzleClient } from "@/lib/DrizzleClient"; +import type { ItemUsageData, ItemEffect } from "@/lib/types"; + +// --- Types --- +export interface DraftItem { + name: string; + description: string; + rarity: string; + type: string; + price: number | null; + iconUrl: string; + imageUrl: string; + usageData: ItemUsageData; + // Temporary state for effect adding flow + pendingEffectType?: string; +} + +// --- State --- +const draftSession = new Map(); + +const getItemTypeOptions = () => [ + { label: "Material", value: "MATERIAL", description: "Used for crafting or trading" }, + { label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" }, + { label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" }, + { label: "Quest Item", value: "QUEST", description: "Required for quests" }, +]; + +const getEffectTypeOptions = () => [ + { label: "Add XP", value: "ADD_XP", description: "Gives XP to the user" }, + { label: "Add Balance", value: "ADD_BALANCE", description: "Gives currency to the user" }, + { label: "Reply Message", value: "REPLY_MESSAGE", description: "Bot replies with a message" }, + { label: "XP Boost", value: "XP_BOOST", description: "Temporarily boosts XP gain" }, + { label: "Temp Role", value: "TEMP_ROLE", description: "Gives a temporary role" }, +]; + +// --- Render --- +export const renderWizard = (userId: string, isDraft = true) => { + let draft = draftSession.get(userId); + + // Initialize if new + if (!draft) { + draft = { + name: "New Item", + description: "No description", + rarity: "Common", + type: "MATERIAL", + price: null, + iconUrl: "", + imageUrl: "", + usageData: { consume: true, effects: [] } // Default Consume to true for now + }; + draftSession.set(userId, draft); + } + + const embed = new EmbedBuilder() + .setTitle(`🛠️ Item Creator: ${draft.name}`) + .setColor("Blue") + .addFields( + { name: "General", value: `**Type:** ${draft.type}\n**Rarity:** ${draft.rarity}\n**Desc:** ${draft.description}`, inline: true }, + { name: "Economy", value: `**Price:** ${draft.price ? `${draft.price} 🪙` : "Not for sale"}`, inline: true }, + { name: "Visuals", value: `**Icon:** ${draft.iconUrl ? "✅ Set" : "❌"}\n**Image:** ${draft.imageUrl ? "✅ Set" : "❌"}`, inline: true }, + ); + + // Effects Display + if (draft.usageData.effects.length > 0) { + const effecto = draft.usageData.effects.map((e, i) => `${i + 1}. **${e.type}**: ${JSON.stringify(e)}`).join("\n"); + embed.addFields({ name: "Usage Effects", value: effecto.substring(0, 1024) }); + } else { + embed.addFields({ name: "Usage Effects", value: "None" }); + } + + if (draft.imageUrl) embed.setImage(draft.imageUrl); + if (draft.iconUrl) embed.setThumbnail(draft.iconUrl); + + // Components + const row1 = new ActionRowBuilder() + .addComponents( + new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"), + new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"), + new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"), + new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"), + ); + + const row2 = new ActionRowBuilder() + .addComponents( + new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"), + new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"), + new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️") + ); + + return { embeds: [embed], components: [row1, row2] }; +}; + +// --- Handler --- +export const handleItemWizardInteraction = async (interaction: Interaction) => { + // Only handle createitem interactions + if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return; + if (!interaction.customId.startsWith("createitem_")) return; + + const userId = interaction.user.id; + let draft = draftSession.get(userId); + + // Special case for Cancel - doesn't need draft checks usually, but we want to clear it + if (interaction.customId === "createitem_cancel") { + draftSession.delete(userId); + if (interaction.isMessageComponent()) { + await interaction.update({ content: "❌ Item creation cancelled.", embeds: [], components: [] }); + } + return; + } + + // Initialize draft if missing for other actions (edge case: bot restart) + if (!draft) { + if (interaction.isMessageComponent()) { + // Create one implicitly to prevent crashes, or warn user + if (interaction.customId === "createitem_start") { + // Allow start + } else { + await interaction.reply({ content: "⚠️ Session expired. Please run `/createitem` again.", ephemeral: true }); + return; + } + } + } + // Re-get draft (guaranteed now if we handled the start/restart) + // Actually renderWizard initializes it, so if we call that we are safe. + // But for Modals we need it. + + if (!draft) { + // Just init it + renderWizard(userId); + draft = draftSession.get(userId)!; + } + + + // --- Routing --- + + // 1. Details Modal + if (interaction.customId === "createitem_details") { + if (!interaction.isButton()) return; + const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details"); + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(draft.name).setStyle(TextInputStyle.Short).setRequired(true)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(draft.description).setStyle(TextInputStyle.Paragraph).setRequired(false)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(draft.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true)) + ); + await interaction.showModal(modal); + return; + } + + // 2. Economy Modal + if (interaction.customId === "createitem_economy") { + if (!interaction.isButton()) return; + const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy"); + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(draft.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true)) + ); + await interaction.showModal(modal); + return; + } + + // 3. Visuals Modal + if (interaction.customId === "createitem_visuals") { + if (!interaction.isButton()) return; + const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals"); + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(draft.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(draft.imageUrl).setStyle(TextInputStyle.Short).setRequired(false)) + ); + await interaction.showModal(modal); + return; + } + + // 4. Type Toggle (Start Select Menu) + if (interaction.customId === "createitem_type_toggle") { + if (!interaction.isButton()) return; + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions()) + ); + await interaction.update({ components: [row as any] }); // Temporary view + return; + } + + if (interaction.customId === "createitem_select_type") { + if (!interaction.isStringSelectMenu()) return; + const selected = interaction.values[0]; + if (selected) { + draft.type = selected; + } + // Re-render + const payload = renderWizard(userId); + await interaction.update(payload); + return; + } + + // 5. Add Effect Flow + if (interaction.customId === "createitem_addeffect_start") { + if (!interaction.isButton()) return; + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions()) + ); + await interaction.update({ components: [row as any] }); + return; + } + + if (interaction.customId === "createitem_select_effect_type") { + if (!interaction.isStringSelectMenu()) return; + const effectType = interaction.values[0]; + draft.pendingEffectType = effectType; + + // Immediately show modal for data collection + // Note: You can't showModal from an update? You CAN showModal from a component interaction (SelectMenu). + // But we shouldn't update the message AND show modal. We must pick one. + // We will show modal. The message remains in "Select Effect" state until modal submit re-renders it. + + let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`); + + if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") { + modal.addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100"))); + } else if (effectType === "REPLY_MESSAGE") { + modal.addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true))); + } else if (effectType === "XP_BOOST") { + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600")) + ); + } else if (effectType === "TEMP_ROLE") { + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600")) + ); + } + + await interaction.showModal(modal); + return; + } + + // 6. Handle Modal Submits + if (interaction.isModalSubmit()) { + if (interaction.customId === "createitem_modal_details") { + draft.name = interaction.fields.getTextInputValue("name"); + draft.description = interaction.fields.getTextInputValue("desc"); + draft.rarity = interaction.fields.getTextInputValue("rarity"); + } + else if (interaction.customId === "createitem_modal_economy") { + const price = parseInt(interaction.fields.getTextInputValue("price")); + draft.price = isNaN(price) || price === 0 ? null : price; + } + else if (interaction.customId === "createitem_modal_visuals") { + draft.iconUrl = interaction.fields.getTextInputValue("icon"); + draft.imageUrl = interaction.fields.getTextInputValue("image"); + } + else if (interaction.customId === "createitem_modal_effect") { + const type = draft.pendingEffectType; + if (type) { + let effect: ItemEffect | null = null; + + if (type === "ADD_XP" || type === "ADD_BALANCE") { + const amount = parseInt(interaction.fields.getTextInputValue("amount")); + if (!isNaN(amount)) effect = { type: type as any, amount }; + } + else if (type === "REPLY_MESSAGE") { + effect = { type: "REPLY_MESSAGE", message: interaction.fields.getTextInputValue("message") }; + } + else if (type === "XP_BOOST") { + const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier")); + const duration = parseInt(interaction.fields.getTextInputValue("duration")); + if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: "XP_BOOST", multiplier, durationSeconds: duration }; + } + else if (type === "TEMP_ROLE") { + const roleId = interaction.fields.getTextInputValue("role_id"); + const duration = parseInt(interaction.fields.getTextInputValue("duration")); + if (roleId && !isNaN(duration)) effect = { type: "TEMP_ROLE", roleId: roleId, durationSeconds: duration }; + } + + if (effect) { + draft.usageData.effects.push(effect); + } + draft.pendingEffectType = undefined; + } + } + + // Re-render + const payload = renderWizard(userId); + await interaction.deferUpdate(); + await interaction.editReply(payload); + return; + } + + // 7. Save + if (interaction.customId === "createitem_save") { + if (!interaction.isButton()) return; + + await interaction.deferUpdate(); // Prepare to save + + try { + await DrizzleClient.insert(items).values({ + name: draft.name, + description: draft.description, + type: draft.type, + rarity: draft.rarity, + price: draft.price ? BigInt(draft.price) : null, + iconUrl: draft.iconUrl, + imageUrl: draft.imageUrl, + usageData: draft.usageData + }); + + draftSession.delete(userId); + await interaction.editReply({ content: `✅ **${draft.name}** has been created successfully!`, embeds: [], components: [] }); + } catch (error: any) { + console.error("Failed to create item:", error); + // Restore state + await interaction.followUp({ content: `❌ Failed to save item: ${error.message}`, ephemeral: true }); + } + } + +};