feat: implement interactive item creation wizard via new /createitem command
This commit is contained in:
329
src/modules/admin/item_wizard.ts
Normal file
329
src/modules/admin/item_wizard.ts
Normal file
@@ -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<string, DraftItem>();
|
||||
|
||||
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<MessageActionRowComponentBuilder>()
|
||||
.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<MessageActionRowComponentBuilder>()
|
||||
.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<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(draft.name).setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(draft.description).setStyle(TextInputStyle.Paragraph).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().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<TextInputBuilder>().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<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(draft.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)),
|
||||
new ActionRowBuilder<TextInputBuilder>().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<StringSelectMenuBuilder>().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<StringSelectMenuBuilder>().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<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100")));
|
||||
} else if (effectType === "REPLY_MESSAGE") {
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
||||
} else if (effectType === "XP_BOOST") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600"))
|
||||
);
|
||||
} else if (effectType === "TEMP_ROLE") {
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)),
|
||||
new ActionRowBuilder<TextInputBuilder>().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 });
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
Reference in New Issue
Block a user