From cdcc3396935b306e33f31e5544e131c955fa9118 Mon Sep 17 00:00:00 2001 From: shni Date: Sun, 5 Oct 2025 21:37:54 -0500 Subject: [PATCH] feat: Enhance offer editing command with interactive selection and improved error handling - Added interactive selection for offers in the `offerEdit` command, allowing users to choose an offer to edit. - Improved permission checks with detailed error messages. - Refactored editor display and components for better readability and user experience. - Updated modal handling for various editing options (base, price, window, limits, metadata) to ensure consistent UI updates. - Enhanced feedback messages for successful updates and cancellations. - Integrated item fetching and formatting for better display of rewards in `pelear`, `pescar`, `plantar`, and `racha` commands. - Improved item display in the shop command with consistent formatting and icons. --- src/commands/messages/game/_helpers.ts | 403 +++++++++++++++++++ src/commands/messages/game/abrir.ts | 49 ++- src/commands/messages/game/areaEdit.ts | 60 ++- src/commands/messages/game/comer.ts | 15 +- src/commands/messages/game/comprar.ts | 4 +- src/commands/messages/game/craftear.ts | 19 +- src/commands/messages/game/encantar.ts | 15 +- src/commands/messages/game/equipar.ts | 6 +- src/commands/messages/game/inventario.ts | 91 +++-- src/commands/messages/game/itemEdit.ts | 73 ++-- src/commands/messages/game/mina.ts | 64 ++- src/commands/messages/game/misionReclamar.ts | 20 +- src/commands/messages/game/mobEdit.ts | 215 ++++++---- src/commands/messages/game/offerEdit.ts | 269 +++++++++++-- src/commands/messages/game/pelear.ts | 60 ++- src/commands/messages/game/pescar.ts | 65 ++- src/commands/messages/game/plantar.ts | 46 ++- src/commands/messages/game/player.ts | 17 +- src/commands/messages/game/racha.ts | 8 +- src/commands/messages/game/tienda.ts | 16 +- 20 files changed, 1192 insertions(+), 323 deletions(-) diff --git a/src/commands/messages/game/_helpers.ts b/src/commands/messages/game/_helpers.ts index 0b53314..97a362c 100644 --- a/src/commands/messages/game/_helpers.ts +++ b/src/commands/messages/game/_helpers.ts @@ -1,6 +1,16 @@ import { prisma } from '../../../core/database/prisma'; import type { GameArea } from '@prisma/client'; import type { ItemProps } from '../../../game/economy/types'; +import type { + Message, + TextBasedChannel, + MessageComponentInteraction, + StringSelectMenuInteraction, + ButtonInteraction, + ModalSubmitInteraction +} from 'discord.js'; +import { MessageFlags } from 'discord.js'; +import { ButtonStyle, ComponentType, TextInputStyle } from 'discord-api-types/v10'; export function parseItemProps(json: unknown): ItemProps { if (!json || typeof json !== 'object') return {}; @@ -99,3 +109,396 @@ export function parseGameArgs(args: string[]): ParsedGameArgs { return { levelArg, providedTool, areaOverride }; } +const DEFAULT_ITEM_ICON = '📦'; + +export function resolveItemIcon(icon?: string | null, fallback = DEFAULT_ITEM_ICON) { + const trimmed = icon?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : fallback; +} + +export function formatItemLabel( + item: { key: string; name?: string | null; icon?: string | null }, + options: { fallbackIcon?: string; bold?: boolean } = {} +): string { + const fallbackIcon = options.fallbackIcon ?? DEFAULT_ITEM_ICON; + const icon = resolveItemIcon(item.icon, fallbackIcon); + const label = (item.name ?? '').trim() || item.key; + const content = `${icon ? `${icon} ` : ''}${label}`.trim(); + return options.bold ? `**${content}**` : content; +} + +export type ItemBasicInfo = { key: string; name: string | null; icon: string | null }; + +export async function fetchItemBasics(guildId: string, keys: string[]): Promise> { + const uniqueKeys = Array.from(new Set(keys.filter((key): key is string => Boolean(key && key.trim())))); + if (uniqueKeys.length === 0) return new Map(); + + const rows = await prisma.economyItem.findMany({ + where: { + key: { in: uniqueKeys }, + OR: [{ guildId }, { guildId: null }], + }, + orderBy: [{ key: 'asc' }, { guildId: 'desc' }], + select: { key: true, name: true, icon: true, guildId: true }, + }); + + const result = new Map(); + for (const row of rows) { + const current = result.get(row.key); + if (!current || row.guildId === guildId) { + result.set(row.key, { key: row.key, name: row.name, icon: row.icon }); + } + } + + for (const key of uniqueKeys) { + if (!result.has(key)) { + result.set(key, { key, name: null, icon: null }); + } + } + + return result; +} + +export interface KeyPickerOption { + value: string; + label: string; + description?: string; + keywords?: string[]; +} + +export interface KeyPickerConfig { + entries: T[]; + getOption: (entry: T) => KeyPickerOption; + title: string; + customIdPrefix: string; + emptyText: string; + placeholder?: string; + filterHint?: string; + accentColor?: number; + userId?: string; +} + +export interface KeyPickerResult { + entry: T | null; + panelMessage: Message | null; + reason: 'selected' | 'empty' | 'cancelled' | 'timeout'; +} + +export async function promptKeySelection( + message: Message, + config: KeyPickerConfig +): Promise> { + const channel = message.channel as TextBasedChannel & { send: Function }; + const userId = config.userId ?? message.author?.id ?? message.member?.user.id; + + const baseOptions = config.entries.map((entry) => { + const option = config.getOption(entry); + const searchText = [option.label, option.description, option.value, ...(option.keywords ?? [])] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return { entry, option, searchText }; + }); + + if (baseOptions.length === 0) { + const emptyPanel = { + type: 17, + accent_color: 0xFFA500, + components: [ + { + type: 10, + content: config.emptyText, + }, + ], + }; + await (channel.send as any)({ + content: null, + flags: 32768, + reply: { messageReference: message.id }, + components: [emptyPanel], + }); + return { entry: null, panelMessage: null, reason: 'empty' }; + } + + let filter = ''; + let page = 0; + const pageSize = 25; + const accentColor = config.accentColor ?? 0x5865F2; + const placeholder = config.placeholder ?? 'Selecciona una opción…'; + + const buildComponents = () => { + const normalizedFilter = filter.trim().toLowerCase(); + const filtered = normalizedFilter + ? baseOptions.filter((item) => item.searchText.includes(normalizedFilter)) + : baseOptions; + const totalFiltered = filtered.length; + const totalPages = Math.max(1, Math.ceil(totalFiltered / pageSize)); + const safePage = Math.min(Math.max(0, page), totalPages - 1); + if (safePage !== page) page = safePage; + const start = safePage * pageSize; + const slice = filtered.slice(start, start + pageSize); + + const pageLabel = `Página ${totalFiltered === 0 ? 0 : safePage + 1}/${totalPages}`; + const statsLine = `Total: **${baseOptions.length}** • Coincidencias: **${totalFiltered}**\n${pageLabel}`; + const filterLine = filter ? `\nFiltro activo: \`${filter}\`` : ''; + const hintLine = config.filterHint ? `\n${config.filterHint}` : ''; + + const display = { + type: 17, + accent_color: accentColor, + components: [ + { type: 10, content: `# ${config.title}` }, + { type: 14, divider: true }, + { + type: 10, + content: `${statsLine}${filterLine}${hintLine}`, + }, + { type: 14, divider: true }, + { + type: 10, + content: totalFiltered === 0 + ? 'No hay resultados para el filtro actual. Ajusta el filtro o limpia la búsqueda.' + : 'Selecciona una opción del menú desplegable para continuar.', + }, + ], + }; + + let options = slice.map(({ option }) => ({ + label: option.label.slice(0, 100), + value: option.value, + description: option.description?.slice(0, 100), + })); + + const selectDisabled = options.length === 0; + if (selectDisabled) { + options = [ + { + label: 'Sin resultados', + value: `${config.customIdPrefix}_empty`, + description: 'Ajusta el filtro para ver opciones.', + }, + ]; + } + + const selectRow = { + type: 1, + components: [ + { + type: 3, + custom_id: `${config.customIdPrefix}_select`, + placeholder, + options, + disabled: selectDisabled, + }, + ], + }; + + const navRow = { + type: 1, + components: [ + { + type: 2, + style: ButtonStyle.Secondary, + label: '◀️', + custom_id: `${config.customIdPrefix}_prev`, + disabled: safePage <= 0 || totalFiltered === 0, + }, + { + type: 2, + style: ButtonStyle.Secondary, + label: '▶️', + custom_id: `${config.customIdPrefix}_next`, + disabled: safePage >= totalPages - 1 || totalFiltered === 0, + }, + { + type: 2, + style: ButtonStyle.Primary, + label: '🔎 Filtro', + custom_id: `${config.customIdPrefix}_filter`, + }, + { + type: 2, + style: ButtonStyle.Secondary, + label: 'Limpiar', + custom_id: `${config.customIdPrefix}_clear`, + disabled: filter.length === 0, + }, + { + type: 2, + style: ButtonStyle.Danger, + label: 'Cancelar', + custom_id: `${config.customIdPrefix}_cancel`, + }, + ], + }; + + return [display, selectRow, navRow]; + }; + + const panelMessage: Message = await (channel.send as any)({ + content: null, + flags: 32768, + reply: { messageReference: message.id }, + components: buildComponents(), + }); + + let resolved = false; + + const result = await new Promise>((resolve) => { + const finish = (entry: T | null, reason: 'selected' | 'cancelled' | 'timeout') => { + if (resolved) return; + resolved = true; + resolve({ entry, panelMessage, reason }); + }; + + const collector = panelMessage.createMessageComponentCollector({ + time: 5 * 60_000, + filter: (i: MessageComponentInteraction) => i.user.id === userId && i.customId.startsWith(config.customIdPrefix), + }); + + collector.on('collect', async (interaction: MessageComponentInteraction) => { + try { + if (interaction.customId === `${config.customIdPrefix}_select` && interaction.isStringSelectMenu()) { + const select = interaction as StringSelectMenuInteraction; + const value = select.values?.[0]; + const selected = baseOptions.find((opt) => opt.option.value === value); + if (!selected) { + await select.reply({ content: '❌ Opción no válida.', flags: MessageFlags.Ephemeral }); + return; + } + + await select.update({ + components: [ + { + type: 17, + accent_color: accentColor, + components: [ + { + type: 10, + content: `⏳ Cargando **${selected.option.label}**…`, + }, + ], + }, + ], + }); + collector.stop('selected'); + finish(selected.entry, 'selected'); + return; + } + + if (interaction.customId === `${config.customIdPrefix}_prev` && interaction.isButton()) { + if (page > 0) page -= 1; + await interaction.update({ components: buildComponents() }); + return; + } + + if (interaction.customId === `${config.customIdPrefix}_next` && interaction.isButton()) { + page += 1; + await interaction.update({ components: buildComponents() }); + return; + } + + if (interaction.customId === `${config.customIdPrefix}_clear` && interaction.isButton()) { + filter = ''; + page = 0; + await interaction.update({ components: buildComponents() }); + return; + } + + if (interaction.customId === `${config.customIdPrefix}_cancel` && interaction.isButton()) { + await interaction.update({ + components: [ + { + type: 17, + accent_color: 0xFF0000, + components: [ + { type: 10, content: '❌ Selección cancelada.' }, + ], + }, + ], + }); + collector.stop('cancelled'); + finish(null, 'cancelled'); + return; + } + + if (interaction.customId === `${config.customIdPrefix}_filter` && interaction.isButton()) { + const modal = { + title: 'Filtrar lista', + customId: `${config.customIdPrefix}_filter_modal`, + components: [ + { + type: ComponentType.Label, + label: 'Texto a buscar', + component: { + type: ComponentType.TextInput, + customId: 'query', + style: TextInputStyle.Short, + required: false, + value: filter, + placeholder: 'Nombre, key, categoría…', + }, + }, + ], + } as const; + + await (interaction as ButtonInteraction).showModal(modal); + let submitted: ModalSubmitInteraction | undefined; + try { + submitted = await interaction.awaitModalSubmit({ + time: 120_000, + filter: (sub) => sub.user.id === userId && sub.customId === `${config.customIdPrefix}_filter_modal`, + }); + } catch { + return; + } + + try { + const value = submitted.components.getTextInputValue('query')?.trim() ?? ''; + filter = value; + page = 0; + await submitted.deferUpdate(); + await panelMessage.edit({ components: buildComponents() }); + } catch { + // ignore errors updating filter + } + return; + } + } catch (err) { + if (!interaction.deferred && !interaction.replied) { + await interaction.reply({ content: '❌ Error procesando la selección.', flags: MessageFlags.Ephemeral }); + } + } + }); + + collector.on('end', async (_collected, reason) => { + if (resolved) return; + resolved = true; + const expiredPanel = { + type: 17, + accent_color: 0xFFA500, + components: [ + { type: 10, content: '⏰ Selección expirada.' }, + ], + }; + try { + await panelMessage.edit({ components: [expiredPanel] }); + } catch {} + const mappedReason: 'selected' | 'cancelled' | 'timeout' = reason === 'cancelled' ? 'cancelled' : 'timeout'; + resolve({ entry: null, panelMessage, reason: mappedReason }); + }); + }); + + return result; +} + +export function sendDisplayReply(message: Message, display: any, extraComponents: any[] = []) { + const channel = message.channel as TextBasedChannel & { send: Function }; + return (channel.send as any)({ + content: null, + flags: 32768, + reply: { messageReference: message.id }, + components: [display, ...extraComponents], + }); +} + diff --git a/src/commands/messages/game/abrir.ts b/src/commands/messages/game/abrir.ts index 9e9767a..e17fa84 100644 --- a/src/commands/messages/game/abrir.ts +++ b/src/commands/messages/game/abrir.ts @@ -1,6 +1,9 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { openChestByKey } from '../../../game/economy/service'; +import { prisma } from '../../../core/database/prisma'; +import { fetchItemBasics, formatItemLabel } from './_helpers'; +import type { ItemBasicInfo } from './_helpers'; export const command: CommandMessage = { name: 'abrir', @@ -12,10 +15,50 @@ export const command: CommandMessage = { run: async (message, args, _client: Amayo) => { const itemKey = args[0]?.trim(); if (!itemKey) { await message.reply('Uso: `!abrir `'); return; } + const userId = message.author.id; + const guildId = message.guild!.id; try { - const res = await openChestByKey(message.author.id, message.guild!.id, itemKey); + const res = await openChestByKey(userId, guildId, itemKey); + + const keyRewards = res.itemsToAdd + .map((it) => it.itemKey) + .filter((key): key is string => typeof key === 'string' && key.trim().length > 0); + + const basicsKeys = [itemKey, ...keyRewards].filter((key): key is string => typeof key === 'string' && key.trim().length > 0); + const infoMap = basicsKeys.length > 0 ? await fetchItemBasics(guildId, basicsKeys) : new Map(); + + const idRewards = res.itemsToAdd + .filter((it) => !it.itemKey && it.itemId) + .map((it) => it.itemId!) + .filter((id, idx, arr) => arr.indexOf(id) === idx); + const itemsById = new Map(); + if (idRewards.length) { + const rows = await prisma.economyItem.findMany({ + where: { id: { in: idRewards } }, + select: { id: true, key: true, name: true, icon: true }, + }); + for (const row of rows) { + const info = { key: row.key, name: row.name, icon: row.icon }; + itemsById.set(row.id, info); + if (!infoMap.has(row.key)) infoMap.set(row.key, info); + } + } + + const chestLabel = formatItemLabel(infoMap.get(itemKey) ?? { key: itemKey, name: null, icon: null }, { bold: true }); const coins = res.coinsDelta ? `🪙 +${res.coinsDelta}` : ''; - const items = res.itemsToAdd.length ? res.itemsToAdd.map(i => `${i.itemKey ?? i.itemId} x${i.qty}`).join(' · ') : ''; + const items = res.itemsToAdd.length + ? res.itemsToAdd.map((i) => { + const info = i.itemKey + ? infoMap.get(i.itemKey) + : i.itemId + ? itemsById.get(i.itemId) + : null; + const label = info + ? formatItemLabel(info) + : formatItemLabel({ key: i.itemKey ?? (i.itemId ?? 'item'), name: null, icon: null }); + return `${label} x${i.qty}`; + }).join(' · ') + : ''; let rolesGiven: string[] = []; let rolesFailed: string[] = []; if (res.rolesToGrant.length && message.member) { @@ -24,7 +67,7 @@ export const command: CommandMessage = { } } const lines = [ - `🎁 Abriste ${itemKey}${res.consumed ? ' (consumido 1)' : ''}`, + `🎁 Abriste ${chestLabel}${res.consumed ? ' (consumido 1)' : ''}`, coins && `Monedas: ${coins}`, items && `Ítems: ${items}`, rolesGiven.length ? `Roles otorgados: ${rolesGiven.map(id=>`<@&${id}>`).join(', ')}` : '', diff --git a/src/commands/messages/game/areaEdit.ts b/src/commands/messages/game/areaEdit.ts index 425baa5..f856325 100644 --- a/src/commands/messages/game/areaEdit.ts +++ b/src/commands/messages/game/areaEdit.ts @@ -4,6 +4,7 @@ import { hasManageGuildOrStaff } from '../../../core/lib/permissions'; import { prisma } from '../../../core/database/prisma'; import { Message, MessageComponentInteraction, MessageFlags, ButtonInteraction, TextBasedChannel } from 'discord.js'; import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10'; +import { promptKeySelection } from './_helpers'; interface AreaState { key: string; @@ -74,7 +75,7 @@ export const command: CommandMessage = { aliases: ['editar-area','areaedit'], cooldown: 10, description: 'Edita una GameArea de este servidor con un editor interactivo.', - usage: 'area-editar ', + usage: 'area-editar', run: async (message, args, _client: Amayo) => { const channel = message.channel as TextBasedChannel & { send: Function }; const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, prisma); @@ -95,50 +96,35 @@ export const command: CommandMessage = { return; } - const key = args[0]?.trim(); - if (!key) { - await (channel.send as any)({ - content: null, - flags: 32768, - components: [{ - type: 17, - accent_color: 0xFFA500, - components: [{ - type: 10, - content: '⚠️ **Uso Incorrecto**\n└ Uso: `!area-editar `' - }] - }], - reply: { messageReference: message.id } - }); - return; - } - const guildId = message.guild!.id; - const area = await prisma.gameArea.findFirst({ where: { key, guildId } }); - if (!area) { - await (channel.send as any)({ - content: null, - flags: 32768, - components: [{ - type: 17, - accent_color: 0xFF0000, - components: [{ - type: 10, - content: '❌ **Área No Encontrada**\n└ No existe un área con esa key en este servidor.' - }] - }], - reply: { messageReference: message.id } - }); + const areas = await prisma.gameArea.findMany({ where: { guildId }, orderBy: [{ key: 'asc' }] }); + const selection = await promptKeySelection(message, { + entries: areas, + customIdPrefix: 'area_edit', + title: 'Selecciona un área para editar', + emptyText: '⚠️ **No hay áreas configuradas.** Usa `!area-crear` para crear una nueva.', + placeholder: 'Elige un área…', + filterHint: 'Puedes filtrar por nombre, key o tipo.', + getOption: (area) => ({ + value: area.id, + label: `${area.name ?? area.key} (${area.type})`, + description: area.key, + keywords: [area.key, area.name ?? '', area.type ?? ''], + }), + }); + + if (!selection.entry || !selection.panelMessage) { return; } - const state: AreaState = { key, name: area.name, type: area.type, config: area.config ?? {}, metadata: area.metadata ?? {} }; + const area = selection.entry; + const state: AreaState = { key: area.key, name: area.name, type: area.type, config: area.config ?? {}, metadata: area.metadata ?? {} }; - const editorMsg = await (channel.send as any)({ + const editorMsg = selection.panelMessage; + await editorMsg.edit({ content: null, flags: 32768, components: buildEditorComponents(state, true), - reply: { messageReference: message.id } }); const collector = editorMsg.createMessageComponentCollector({ time: 30*60_000, filter: (i)=> i.user.id === message.author.id }); diff --git a/src/commands/messages/game/comer.ts b/src/commands/messages/game/comer.ts index 3383e9d..84f935b 100644 --- a/src/commands/messages/game/comer.ts +++ b/src/commands/messages/game/comer.ts @@ -1,6 +1,7 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { useConsumableByKey } from '../../../game/consumables/service'; +import { fetchItemBasics, formatItemLabel } from './_helpers'; export const command: CommandMessage = { name: 'comer', @@ -12,11 +13,19 @@ export const command: CommandMessage = { run: async (message, args, _client: Amayo) => { const itemKey = args[0]?.trim(); if (!itemKey) { await message.reply('Uso: `!comer `'); return; } + const guildId = message.guild!.id; + const userId = message.author.id; + let itemInfo: { key: string; name: string | null; icon: string | null } = { key: itemKey, name: null, icon: null }; try { - const res = await useConsumableByKey(message.author.id, message.guild!.id, itemKey); - await message.reply(`🍽️ Usaste ${itemKey}. Curado: +${res.healed} HP.`); + const basics = await fetchItemBasics(guildId, [itemKey]); + itemInfo = basics.get(itemKey) ?? itemInfo; + + const res = await useConsumableByKey(userId, guildId, itemKey); + const label = formatItemLabel(itemInfo, { bold: true }); + await message.reply(`🍽️ Usaste ${label}. Curado: +${res.healed} HP.`); } catch (e: any) { - await message.reply(`❌ No se pudo usar ${itemKey}: ${e?.message ?? e}`); + const label = formatItemLabel(itemInfo, { bold: true }); + await message.reply(`❌ No se pudo usar ${label}: ${e?.message ?? e}`); } } }; diff --git a/src/commands/messages/game/comprar.ts b/src/commands/messages/game/comprar.ts index 99ca694..606ba01 100644 --- a/src/commands/messages/game/comprar.ts +++ b/src/commands/messages/game/comprar.ts @@ -1,6 +1,7 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { buyFromOffer } from '../../../game/economy/service'; +import { formatItemLabel } from './_helpers'; export const command: CommandMessage = { name: 'comprar', @@ -15,7 +16,8 @@ export const command: CommandMessage = { if (!offerId) { await message.reply('Uso: `!comprar [qty]`'); return; } try { const res = await buyFromOffer(message.author.id, message.guild!.id, offerId, qty); - await message.reply(`🛒 Comprado: ${res.item.key} x${res.qty}`); + const label = formatItemLabel(res.item, { bold: true }); + await message.reply(`🛒 Comprado: ${label} x${res.qty}`); } catch (e: any) { await message.reply(`❌ No se pudo comprar: ${e?.message ?? e}`); } diff --git a/src/commands/messages/game/craftear.ts b/src/commands/messages/game/craftear.ts index 8cd3c9c..cf86a2e 100644 --- a/src/commands/messages/game/craftear.ts +++ b/src/commands/messages/game/craftear.ts @@ -1,6 +1,7 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { craftByProductKey } from '../../../game/economy/service'; +import { fetchItemBasics, formatItemLabel } from './_helpers'; export const command: CommandMessage = { name: 'craftear', @@ -14,21 +15,33 @@ export const command: CommandMessage = { const times = Math.max(1, parseInt(args[1] || '1', 10) || 1); if (!productKey) { await message.reply('Uso: `!craftear [veces]`'); return; } + const guildId = message.guild!.id; + const userId = message.author.id; + let itemInfo: { key: string; name: string | null; icon: string | null } = { key: productKey, name: null, icon: null }; + try { + const basics = await fetchItemBasics(guildId, [productKey]); + itemInfo = basics.get(productKey) ?? itemInfo; + } catch (err) { + console.error('No se pudo resolver info de item para craftear', err); + } + let crafted = 0; let lastError: any = null; for (let i = 0; i < times; i++) { try { - const res = await craftByProductKey(message.author.id, message.guild!.id, productKey); + const res = await craftByProductKey(userId, guildId, productKey); crafted += res.added; + itemInfo = { key: res.product.key, name: res.product.name, icon: res.product.icon }; } catch (e: any) { lastError = e; break; } } + const label = formatItemLabel(itemInfo, { bold: true }); if (crafted > 0) { - await message.reply(`🛠️ Crafteado ${productKey} x${crafted * 1}.`); + await message.reply(`🛠️ Crafteado ${label} x${crafted}.`); } else { - await message.reply(`❌ No se pudo craftear: ${lastError?.message ?? 'revise ingredientes/receta'}`); + await message.reply(`❌ No se pudo craftear ${label}: ${lastError?.message ?? 'revise ingredientes/receta'}`); } } }; diff --git a/src/commands/messages/game/encantar.ts b/src/commands/messages/game/encantar.ts index 5babb28..bbd15e4 100644 --- a/src/commands/messages/game/encantar.ts +++ b/src/commands/messages/game/encantar.ts @@ -1,6 +1,7 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { applyMutationToInventory } from '../../../game/mutations/service'; +import { fetchItemBasics, formatItemLabel } from './_helpers'; export const command: CommandMessage = { name: 'encantar', @@ -13,11 +14,19 @@ export const command: CommandMessage = { const itemKey = args[0]?.trim(); const mutationKey = args[1]?.trim(); if (!itemKey || !mutationKey) { await message.reply('Uso: `!encantar `'); return; } + const guildId = message.guild!.id; + const userId = message.author.id; + let itemInfo: { key: string; name: string | null; icon: string | null } = { key: itemKey, name: null, icon: null }; try { - await applyMutationToInventory(message.author.id, message.guild!.id, itemKey, mutationKey); - await message.reply(`✨ Aplicada mutación ${mutationKey} a ${itemKey}.`); + const basics = await fetchItemBasics(guildId, [itemKey]); + itemInfo = basics.get(itemKey) ?? itemInfo; + + await applyMutationToInventory(userId, guildId, itemKey, mutationKey); + const label = formatItemLabel(itemInfo, { bold: true }); + await message.reply(`✨ Aplicada mutación \`${mutationKey}\` a ${label}.`); } catch (e: any) { - await message.reply(`❌ No se pudo encantar: ${e?.message ?? e}`); + const label = formatItemLabel(itemInfo, { bold: true }); + await message.reply(`❌ No se pudo encantar ${label}: ${e?.message ?? e}`); } } }; diff --git a/src/commands/messages/game/equipar.ts b/src/commands/messages/game/equipar.ts index 2c036f4..3505ae2 100644 --- a/src/commands/messages/game/equipar.ts +++ b/src/commands/messages/game/equipar.ts @@ -2,6 +2,7 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { setEquipmentSlot } from '../../../game/combat/equipmentService'; import { prisma } from '../../../core/database/prisma'; +import { formatItemLabel } from './_helpers'; export const command: CommandMessage = { name: 'equipar', @@ -22,11 +23,12 @@ export const command: CommandMessage = { const item = await prisma.economyItem.findFirst({ where: { key: itemKey, OR: [{ guildId }, { guildId: null }] }, orderBy: [{ guildId: 'desc' }] }); if (!item) { await message.reply('❌ Item no encontrado.'); return; } + const label = formatItemLabel(item, { bold: true }); const inv = await prisma.inventoryEntry.findUnique({ where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } } }); - if (!inv || inv.quantity <= 0) { await message.reply('❌ No tienes este item en tu inventario.'); return; } + if (!inv || inv.quantity <= 0) { await message.reply(`❌ No tienes ${label} en tu inventario.`); return; } await setEquipmentSlot(userId, guildId, slot, item.id); - await message.reply(`🧰 Equipado en ${slot}: ${item.key}`); + await message.reply(`🧰 Equipado en ${slot}: ${label}`); } }; diff --git a/src/commands/messages/game/inventario.ts b/src/commands/messages/game/inventario.ts index 313fe4a..6ffaf22 100644 --- a/src/commands/messages/game/inventario.ts +++ b/src/commands/messages/game/inventario.ts @@ -4,6 +4,8 @@ import { prisma } from '../../../core/database/prisma'; import { getOrCreateWallet } from '../../../game/economy/service'; import { getEquipment, getEffectiveStats } from '../../../game/combat/equipmentService'; import type { ItemProps } from '../../../game/economy/types'; +import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2'; +import { sendDisplayReply, formatItemLabel } from './_helpers'; const PAGE_SIZE = 15; @@ -28,6 +30,8 @@ function fmtStats(props: ItemProps) { return parts.length ? ` (${parts.join(' ')})` : ''; } +const INVENTORY_ACCENT = 0xFEE75C; + export const command: CommandMessage = { name: 'inventario', type: 'message', @@ -60,15 +64,23 @@ export const command: CommandMessage = { const tool = fmtTool(props); const st = fmtStats(props); const tags = (itemRow.tags || []).join(', '); - await message.reply([ - `📦 ${itemRow.name || itemRow.key} — x${qty}`, - `Key: ${itemRow.key}`, - itemRow.category ? `Categoría: ${itemRow.category}` : '', - tags ? `Tags: ${tags}` : '', - tool ? `Herramienta: ${tool}` : '', - st ? `Bonos: ${st}` : '', - props.craftingOnly ? 'Solo crafteo' : '', - ].filter(Boolean).join('\n')); + const detailLines = [ + `**Cantidad:** x${qty}`, + `**Key:** \`${itemRow.key}\``, + itemRow.category ? `**Categoría:** ${itemRow.category}` : '', + tags ? `**Tags:** ${tags}` : '', + tool ? `**Herramienta:** ${tool}` : '', + st ? `**Bonos:** ${st}` : '', + props.craftingOnly ? '⚠️ Solo crafteo' : '', + ].filter(Boolean).join('\n'); + + const display = buildDisplay(INVENTORY_ACCENT, [ + textBlock(`# ${formatItemLabel(itemRow, { bold: true })}`), + dividerBlock(), + textBlock(detailLines || '*Sin información adicional.*'), + ]); + + await sendDisplayReply(message, display); return; } } @@ -88,37 +100,56 @@ export const command: CommandMessage = { .sort((a, b) => (b.quantity - a.quantity) || a.item.key.localeCompare(b.item.key)) .slice(start, start + PAGE_SIZE); - const lines: string[] = []; - // header con saldo y equipo - lines.push(`💰 Monedas: ${wallet.coins}`); - const gear: string[] = []; - if (weapon) gear.push(`🗡️ ${weapon.key}`); - if (armor) gear.push(`🛡️ ${armor.key}`); - if (cape) gear.push(`🧥 ${cape.key}`); - if (gear.length) lines.push(`🧰 Equipo: ${gear.join(' · ')}`); - lines.push(`❤️ HP: ${stats.hp}/${stats.maxHp} · ⚔️ ATK: ${stats.damage} · 🛡️ DEF: ${stats.defense}`); + const gear: string[] = []; + if (weapon) gear.push(`🗡️ ${formatItemLabel(weapon, { fallbackIcon: '' })}`); + if (armor) gear.push(`🛡️ ${formatItemLabel(armor, { fallbackIcon: '' })}`); + if (cape) gear.push(`🧥 ${formatItemLabel(cape, { fallbackIcon: '' })}`); + const headerLines = [ + `💰 Monedas: **${wallet.coins}**`, + gear.length ? `🧰 Equipo: ${gear.join(' · ')}` : '', + `❤️ HP: ${stats.hp}/${stats.maxHp} · ⚔️ ATK: ${stats.damage} · 🛡️ DEF: ${stats.defense}`, + filter ? `🔍 Filtro: ${filter}` : '', + ].filter(Boolean).join('\n'); + + const blocks = [ + textBlock('# 📦 Inventario'), + dividerBlock(), + textBlock(headerLines), + ]; if (!pageItems.length) { - lines.push(filter ? `No hay ítems que coincidan con "${filter}".` : 'No tienes ítems en tu inventario.'); - await message.reply(lines.join('\n')); + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(filter ? `No hay ítems que coincidan con "${filter}".` : 'No tienes ítems en tu inventario.')); + const display = buildDisplay(INVENTORY_ACCENT, blocks); + await sendDisplayReply(message, display); return; } - lines.push(`\n📦 Inventario (página ${page}/${totalPages}${filter ? `, filtro: ${filter}` : ''})`); + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(`📦 Inventario (página ${page}/${totalPages}${filter ? `, filtro: ${filter}` : ''})`)); + blocks.push(dividerBlock({ divider: false, spacing: 1 })); - for (const e of pageItems) { - const p = parseItemProps(e.item.props); - const tool = fmtTool(p); - const st = fmtStats(p); - const name = e.item.name || e.item.key; - lines.push(`• ${name} — x${e.quantity}${tool ? ` ${tool}` : ''}${st}`); - } + pageItems.forEach((entry, index) => { + const props = parseItemProps(entry.item.props); + const tool = fmtTool(props); + const st = fmtStats(props); + const label = formatItemLabel(entry.item); + blocks.push(textBlock(`• ${label} — x${entry.quantity}${tool ? ` ${tool}` : ''}${st}`)); + if (index < pageItems.length - 1) { + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + } + }); if (totalPages > 1) { - lines.push(`\nUsa: \`!inv ${filter ? `${page+1} ${filter}` : page+1}\` para la siguiente página.`); + const nextPage = Math.min(page + 1, totalPages); + const nextCommand = filter ? `!inv ${nextPage} ${filter}` : `!inv ${nextPage}`; + const backtick = '`'; + blocks.push(dividerBlock({ divider: false, spacing: 2 })); + blocks.push(textBlock(`💡 Usa ${backtick}${nextCommand}${backtick} para la siguiente página.`)); } - await message.reply(lines.join('\n')); + const display = buildDisplay(INVENTORY_ACCENT, blocks); + await sendDisplayReply(message, display); } }; diff --git a/src/commands/messages/game/itemEdit.ts b/src/commands/messages/game/itemEdit.ts index c3319bb..6b8217a 100644 --- a/src/commands/messages/game/itemEdit.ts +++ b/src/commands/messages/game/itemEdit.ts @@ -4,6 +4,7 @@ import type { CommandMessage } from '../../../core/types/commands'; import { hasManageGuildOrStaff } from '../../../core/lib/permissions'; import logger from '../../../core/lib/logger'; import type Amayo from '../../../core/client'; +import { promptKeySelection, resolveItemIcon } from './_helpers'; interface ItemEditorState { key: string; @@ -24,8 +25,8 @@ export const command: CommandMessage = { cooldown: 10, description: 'Edita un EconomyItem existente del servidor con un pequeño editor interactivo.', category: 'Economía', - usage: 'item-editar ', - run: async (message: Message, args: string[], client: Amayo) => { + usage: 'item-editar', + run: async (message: Message, _args: string[], client: Amayo) => { const channel = message.channel as TextBasedChannel & { send: Function }; const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, client.prisma); if (!allowed) { @@ -45,53 +46,43 @@ export const command: CommandMessage = { return; } - const key = args[0]?.trim(); - if (!key) { - await (channel.send as any)({ - content: null, - flags: 32768, - components: [{ - type: 17, - accent_color: 0xFFA500, - components: [{ - type: 10, - content: '⚠️ **Uso Incorrecto**\n└ Uso: `!item-editar `' - }] - }], - reply: { messageReference: message.id } - }); - return; - } - const guildId = message.guild!.id; + const items = await client.prisma.economyItem.findMany({ where: { guildId }, orderBy: [{ key: 'asc' }] }); + const selection = await promptKeySelection(message, { + entries: items, + customIdPrefix: 'item_edit', + title: 'Selecciona un ítem para editar', + emptyText: '⚠️ **No hay ítems locales configurados.** Usa `!item-crear` primero.', + placeholder: 'Elige un ítem…', + filterHint: 'Filtra por nombre, key, categoría o tag.', + getOption: (item) => { + const icon = resolveItemIcon(item.icon); + const label = `${icon} ${(item.name ?? item.key)}`.trim(); + const tags = Array.isArray(item.tags) ? item.tags : []; + return { + value: item.id, + label: label.slice(0, 100), + description: item.key, + keywords: [item.key, item.name ?? '', item.category ?? '', ...tags], + }; + }, + }); - const existing = await client.prisma.economyItem.findFirst({ where: { key, guildId } }); - if (!existing) { - await (channel.send as any)({ - content: null, - flags: 32768, - components: [{ - type: 17, - accent_color: 0xFF0000, - components: [{ - type: 10, - content: '❌ **Item No Encontrado**\n└ No existe un item con esa key en este servidor.' - }] - }], - reply: { messageReference: message.id } - }); + if (!selection.entry || !selection.panelMessage) { return; } + const existing = selection.entry; + const state: ItemEditorState = { - key, + key: existing.key, name: existing.name, description: existing.description || undefined, category: existing.category || undefined, icon: existing.icon || undefined, stackable: existing.stackable ?? true, - maxPerInventory: existing.maxPerInventory || null, - tags: existing.tags || [], + maxPerInventory: existing.maxPerInventory ?? null, + tags: Array.isArray(existing.tags) ? existing.tags : [], props: existing.props || {}, }; @@ -114,7 +105,7 @@ export const command: CommandMessage = { components: [ { type: 10, - content: `# 🛠️ Editando Item: \`${key}\`` + content: `# 🛠️ Editando Item: \`${state.key}\`` }, { type: 14, divider: true }, { @@ -149,11 +140,11 @@ export const command: CommandMessage = { } ]; - const editorMsg = await (channel.send as any)({ + const editorMsg = selection.panelMessage; + await editorMsg.edit({ content: null, flags: 32768, components: buildEditorComponents(), - reply: { messageReference: message.id } }); const collector = editorMsg.createMessageComponentCollector({ time: 30 * 60_000, filter: (i) => i.user.id === message.author.id }); diff --git a/src/commands/messages/game/mina.ts b/src/commands/messages/game/mina.ts index 3aebf1f..0fa0b33 100644 --- a/src/commands/messages/game/mina.ts +++ b/src/commands/messages/game/mina.ts @@ -1,10 +1,13 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { runMinigame } from '../../../game/minigames/service'; -import { getDefaultLevel, findBestToolKey, parseGameArgs, resolveGuildAreaWithFallback, resolveAreaByType } from './_helpers'; +import { getDefaultLevel, findBestToolKey, parseGameArgs, resolveGuildAreaWithFallback, resolveAreaByType, sendDisplayReply, fetchItemBasics, formatItemLabel } from './_helpers'; import { updateStats } from '../../../game/stats/service'; import { updateQuestProgress } from '../../../game/quests/service'; import { checkAchievements } from '../../../game/achievements/service'; +import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2'; + +const MINING_ACCENT = 0xC27C0E; export const command: CommandMessage = { name: 'mina', @@ -41,6 +44,12 @@ export const command: CommandMessage = { try { const result = await runMinigame(userId, guildId, area.key, level, { toolKey: toolKey ?? undefined }); + + const rewardKeys = result.rewards + .filter((r): r is { type: 'item'; itemKey: string; qty?: number } => r.type === 'item' && Boolean(r.itemKey)) + .map((r) => r.itemKey!); + if (result.tool?.key) rewardKeys.push(result.tool.key); + const rewardItems = await fetchItemBasics(guildId, rewardKeys); // Actualizar stats await updateStats(userId, guildId, { minesCompleted: 1 }); @@ -51,25 +60,44 @@ export const command: CommandMessage = { // Verificar logros const newAchievements = await checkAchievements(userId, guildId, 'mine_count'); - const rewards = result.rewards.map(r => r.type === 'coins' ? `🪙 +${r.amount}` : `📦 ${r.itemKey} x${r.qty}`).join(' · ') || '—'; - const mobs = result.mobs.length ? result.mobs.join(', ') : '—'; - const toolInfo = result.tool?.key ? `🔧 ${result.tool.key}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta} dur.)`}` : '—'; - - let response = globalNotice ? `${globalNotice}\n\n` : ''; - response += `⛏️ Mina (nivel ${level}) -Recompensas: ${rewards} -Mobs: ${mobs} -Herramienta: ${toolInfo}`; + const rewardLines = result.rewards.length + ? result.rewards.map((r) => { + if (r.type === 'coins') return `• 🪙 +${r.amount}`; + const info = rewardItems.get(r.itemKey!); + const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); + return `• ${label} x${r.qty ?? 1}`; + }).join('\n') + : '• —'; + const mobsLines = result.mobs.length + ? result.mobs.map(m => `• ${m}`).join('\n') + : '• —'; + const toolInfo = result.tool?.key + ? `${formatItemLabel(rewardItems.get(result.tool.key) ?? { key: result.tool.key, name: null, icon: null }, { fallbackIcon: '🔧' })}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta ?? 0} dur.)`}` + : '—'; - // Notificar logros desbloqueados - if (newAchievements.length > 0) { - response += `\n\n🏆 ¡Logro desbloqueado!`; - for (const ach of newAchievements) { - response += `\n✨ **${ach.name}** - ${ach.description}`; - } + const blocks = [textBlock('# ⛏️ Mina')]; + + if (globalNotice) { + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(globalNotice)); } - - await message.reply(response); + + blocks.push(dividerBlock()); + const areaScope = source === 'global' ? '🌐 Configuración global' : '📍 Configuración local'; + blocks.push(textBlock(`**Área:** \`${area.key}\` • ${areaScope}\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`)); + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(`**Recompensas**\n${rewardLines}`)); + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(`**Mobs**\n${mobsLines}`)); + + if (newAchievements.length > 0) { + blocks.push(dividerBlock({ divider: false, spacing: 2 })); + const achLines = newAchievements.map(ach => `✨ **${ach.name}** — ${ach.description}`).join('\n'); + blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`)); + } + + const display = buildDisplay(MINING_ACCENT, blocks); + await sendDisplayReply(message, display); } catch (e: any) { await message.reply(`❌ No se pudo minar: ${e?.message ?? e}`); } diff --git a/src/commands/messages/game/misionReclamar.ts b/src/commands/messages/game/misionReclamar.ts index 736d2b7..d48d1a6 100644 --- a/src/commands/messages/game/misionReclamar.ts +++ b/src/commands/messages/game/misionReclamar.ts @@ -2,6 +2,7 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { claimQuestReward, getPlayerQuests } from '../../../game/quests/service'; import { EmbedBuilder } from 'discord.js'; +import { fetchItemBasics, formatItemLabel } from './_helpers'; export const command: CommandMessage = { name: 'mision-reclamar', @@ -41,6 +42,21 @@ export const command: CommandMessage = { // Reclamar recompensa const { quest, rewards } = await claimQuestReward(userId, guildId, selected.quest.id); + const rewardData = (quest.rewards as any) ?? {}; + const formattedRewards: string[] = []; + if (rewardData.coins) formattedRewards.push(`💰 **${rewardData.coins.toLocaleString()}** monedas`); + if (rewardData.items && Array.isArray(rewardData.items) && rewardData.items.length) { + const basics = await fetchItemBasics(guildId, rewardData.items.map((item: any) => item.key)); + for (const item of rewardData.items) { + const info = basics.get(item.key) ?? { key: item.key, name: null, icon: null }; + const label = formatItemLabel(info, { bold: true }); + formattedRewards.push(`${label} ×${item.quantity}`); + } + } + if (rewardData.xp) formattedRewards.push(`⭐ **${rewardData.xp}** XP`); + if (rewardData.title) formattedRewards.push(`🏆 Título: **${rewardData.title}**`); + const rewardsDisplay = formattedRewards.length > 0 ? formattedRewards : rewards; + const embed = new EmbedBuilder() .setColor(0x00FF00) .setTitle('🎉 ¡Misión Completada!') @@ -48,10 +64,10 @@ export const command: CommandMessage = { .setThumbnail(message.author.displayAvatarURL({ size: 128 })); // Mostrar recompensas - if (rewards.length > 0) { + if (rewardsDisplay.length > 0) { embed.addFields({ name: '🎁 Recompensas Recibidas', - value: rewards.join('\n'), + value: rewardsDisplay.join('\n'), inline: false }); } diff --git a/src/commands/messages/game/mobEdit.ts b/src/commands/messages/game/mobEdit.ts index 7bdfecc..ce4822d 100644 --- a/src/commands/messages/game/mobEdit.ts +++ b/src/commands/messages/game/mobEdit.ts @@ -4,6 +4,7 @@ import type { CommandMessage } from '../../../core/types/commands'; import { hasManageGuildOrStaff } from '../../../core/lib/permissions'; import logger from '../../../core/lib/logger'; import type Amayo from '../../../core/client'; +import { promptKeySelection } from './_helpers'; interface MobEditorState { key: string; @@ -64,94 +65,158 @@ export const command: CommandMessage = { cooldown: 10, description: 'Edita un Mob (enemigo) de este servidor con editor interactivo.', category: 'Minijuegos', - usage: 'mob-editar ', - run: async (message: Message, args: string[], client: Amayo) => { + usage: 'mob-editar', + run: async (message: Message, _args: string[], client: Amayo) => { + const channel = message.channel as TextBasedChannel & { send: Function }; const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, client.prisma); - if (!allowed) { await message.reply('❌ No tienes permisos de ManageGuild ni rol de staff.'); return; } - const key = args[0]?.trim(); - if (!key) { await message.reply('Uso: `!mob-editar `'); return; } - const guildId = message.guild!.id; + if (!allowed) { + await (channel.send as any)({ + content: null, + flags: 32768, + components: [{ + type: 17, + accent_color: 0xFF0000, + components: [{ + type: 10, + content: '❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.' + }] + }], + reply: { messageReference: message.id } + }); + return; + } - const mob = await client.prisma.mob.findFirst({ where: { key, guildId } }); - if (!mob) { await message.reply('❌ No existe un mob con esa key en este servidor.'); return; } + const guildId = message.guild!.id; + const mobs = await client.prisma.mob.findMany({ where: { guildId }, orderBy: [{ key: 'asc' }] }); + const selection = await promptKeySelection(message, { + entries: mobs, + customIdPrefix: 'mob_edit', + title: 'Selecciona un mob para editar', + emptyText: '⚠️ **No hay mobs configurados.** Usa `!mob-crear` primero.', + placeholder: 'Elige un mob…', + filterHint: 'Filtra por nombre, key o categoría.', + getOption: (mob) => ({ + value: mob.id, + label: mob.name ?? mob.key, + description: [mob.category ?? 'Sin categoría', mob.key].filter(Boolean).join(' • '), + keywords: [mob.key, mob.name ?? '', mob.category ?? ''], + }), + }); + + if (!selection.entry || !selection.panelMessage) { + return; + } + + const mob = selection.entry; const state: MobEditorState = { - key, + key: mob.key, name: mob.name, category: mob.category ?? undefined, stats: mob.stats ?? {}, drops: mob.drops ?? {}, }; - const channel = message.channel as TextBasedChannel & { send: Function }; - const editorMsg = await channel.send({ - content: `👾 Editor de Mob (editar): \`${key}\``, - components: [ { type: 1, components: [ - { type: 2, style: ButtonStyle.Primary, label: 'Base', custom_id: 'mb_base' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Stats (JSON)', custom_id: 'mb_stats' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Drops (JSON)', custom_id: 'mb_drops' }, - { type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'mb_save' }, - { type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'mb_cancel' }, - ] } ], + const buildEditorComponents = () => [ + createMobDisplay(state, true), + { + type: 1, + components: [ + { type: 2, style: ButtonStyle.Primary, label: 'Base', custom_id: 'mb_base' }, + { type: 2, style: ButtonStyle.Secondary, label: 'Stats (JSON)', custom_id: 'mb_stats' }, + { type: 2, style: ButtonStyle.Secondary, label: 'Drops (JSON)', custom_id: 'mb_drops' }, + { type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'mb_save' }, + { type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'mb_cancel' }, + ] + } + ]; + + const editorMsg = selection.panelMessage; + await editorMsg.edit({ + content: null, + flags: 32768, + components: buildEditorComponents(), }); - const collector = editorMsg.createMessageComponentCollector({ time: 30*60_000, filter: (i)=> i.user.id === message.author.id }); + const collector = editorMsg.createMessageComponentCollector({ time: 30 * 60_000, filter: (i) => i.user.id === message.author.id }); collector.on('collect', async (i: MessageComponentInteraction) => { try { if (!i.isButton()) return; - if (i.customId === 'mb_cancel') { - await i.deferUpdate(); - await editorMsg.edit({ - flags: 32768, - components: [{ - type: 17, - accent_color: 0xFF0000, + switch (i.customId) { + case 'mb_cancel': + await i.deferUpdate(); + await editorMsg.edit({ + content: null, + flags: 32768, components: [{ - type: 9, + type: 17, + accent_color: 0xFF0000, components: [{ type: 10, content: '**❌ Editor cancelado.**' }] }] - }] - }); - collector.stop('cancel'); - return; - } - if (i.customId === 'mb_base') { await showBaseModal(i as ButtonInteraction, state, editorMsg, true); return; } - if (i.customId === 'mb_stats') { await showJsonModal(i as ButtonInteraction, state, 'stats', 'Stats del Mob (JSON)', editorMsg, true); return; } - if (i.customId === 'mb_drops') { await showJsonModal(i as ButtonInteraction, state, 'drops', 'Drops del Mob (JSON)', editorMsg, true); return; } - if (i.customId === 'mb_save') { - if (!state.name) { await i.reply({ content: '❌ Falta el nombre del mob.', flags: MessageFlags.Ephemeral }); return; } - await client.prisma.mob.update({ where: { id: mob.id }, data: { name: state.name!, category: state.category ?? null, stats: state.stats ?? {}, drops: state.drops ?? {} } }); - await i.reply({ content: '✅ Mob actualizado!', flags: MessageFlags.Ephemeral }); - await editorMsg.edit({ - flags: 32768, - components: [{ - type: 17, - accent_color: 0x00FF00, + }); + collector.stop('cancel'); + return; + case 'mb_base': + await showBaseModal(i as ButtonInteraction, state, editorMsg, buildEditorComponents); + return; + case 'mb_stats': + await showJsonModal(i as ButtonInteraction, state, 'stats', 'Stats del Mob (JSON)', editorMsg, buildEditorComponents); + return; + case 'mb_drops': + await showJsonModal(i as ButtonInteraction, state, 'drops', 'Drops del Mob (JSON)', editorMsg, buildEditorComponents); + return; + case 'mb_save': + if (!state.name) { + await i.reply({ content: '❌ Falta el nombre del mob.', flags: MessageFlags.Ephemeral }); + return; + } + await client.prisma.mob.update({ where: { id: mob.id }, data: { name: state.name!, category: state.category ?? null, stats: state.stats ?? {}, drops: state.drops ?? {} } }); + await i.reply({ content: '✅ Mob actualizado!', flags: MessageFlags.Ephemeral }); + await editorMsg.edit({ + content: null, + flags: 32768, components: [{ - type: 9, + type: 17, + accent_color: 0x00FF00, components: [{ type: 10, content: `**✅ Mob \`${state.key}\` actualizado exitosamente.**` }] }] - }] - }); - collector.stop('saved'); - return; + }); + collector.stop('saved'); + return; } } catch (err) { - logger.error({err}, 'mob-editar'); + logger.error({ err }, 'mob-editar'); if (!i.deferred && !i.replied) await i.reply({ content: '❌ Error procesando la acción.', flags: MessageFlags.Ephemeral }); } }); - collector.on('end', async (_c,r)=> { if (r==='time') { try { await editorMsg.edit({ content:'⏰ Editor expirado.', components: [] }); } catch {} } }); + collector.on('end', async (_c, reason) => { + if (reason === 'time') { + try { + await editorMsg.edit({ + content: null, + flags: 32768, + components: [{ + type: 17, + accent_color: 0xFFA500, + components: [{ + type: 10, + content: '**⏰ Editor expirado.**' + }] + }] + }); + } catch {} + } + }); }, }; -async function showBaseModal(i: ButtonInteraction, state: MobEditorState, editorMsg: Message, editing: boolean) { +async function showBaseModal(i: ButtonInteraction, state: MobEditorState, editorMsg: Message, buildComponents: () => any[]) { const modal = { title: 'Base del Mob', customId: 'mb_base_modal', components: [ { type: ComponentType.Label, label: 'Nombre', component: { type: ComponentType.TextInput, customId: 'name', style: TextInputStyle.Short, required: true, value: state.name ?? '' } }, { type: ComponentType.Label, label: 'Categoría (opcional)', component: { type: ComponentType.TextInput, customId: 'category', style: TextInputStyle.Short, required: false, value: state.category ?? '' } }, @@ -162,30 +227,16 @@ async function showBaseModal(i: ButtonInteraction, state: MobEditorState, editor state.name = sub.components.getTextInputValue('name').trim(); const cat = sub.components.getTextInputValue('category')?.trim(); state.category = cat || undefined; - await sub.reply({ content: '✅ Base actualizada.', flags: MessageFlags.Ephemeral }); - - // Refresh display - const newDisplay = createMobDisplay(state, editing); + await sub.deferUpdate(); await editorMsg.edit({ + content: null, flags: 32768, - components: [ - newDisplay, - { - type: 1, - components: [ - { type: 2, style: ButtonStyle.Primary, label: 'Base', custom_id: 'mb_base' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Stats (JSON)', custom_id: 'mb_stats' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Drops (JSON)', custom_id: 'mb_drops' }, - { type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'mb_save' }, - { type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'mb_cancel' }, - ] - } - ] + components: buildComponents() }); } catch {} } -async function showJsonModal(i: ButtonInteraction, state: MobEditorState, field: 'stats'|'drops', title: string, editorMsg: Message, editing: boolean) { +async function showJsonModal(i: ButtonInteraction, state: MobEditorState, field: 'stats'|'drops', title: string, editorMsg: Message, buildComponents: () => any[]) { const current = JSON.stringify(state[field] ?? {}); const modal = { title, customId: `mb_json_${field}`, components: [ { type: ComponentType.Label, label: 'JSON', component: { type: ComponentType.TextInput, customId: 'json', style: TextInputStyle.Paragraph, required: false, value: current.slice(0,4000) } }, @@ -197,33 +248,19 @@ async function showJsonModal(i: ButtonInteraction, state: MobEditorState, field: if (raw) { try { state[field] = JSON.parse(raw); - await sub.reply({ content: '✅ Guardado.', flags: MessageFlags.Ephemeral }); + await sub.deferUpdate(); } catch { await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral }); return; } } else { state[field] = {}; - await sub.reply({ content: 'ℹ️ Limpio.', flags: MessageFlags.Ephemeral }); + await sub.deferUpdate(); } - - // Refresh display - const newDisplay = createMobDisplay(state, editing); await editorMsg.edit({ + content: null, flags: 32768, - components: [ - newDisplay, - { - type: 1, - components: [ - { type: 2, style: ButtonStyle.Primary, label: 'Base', custom_id: 'mb_base' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Stats (JSON)', custom_id: 'mb_stats' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Drops (JSON)', custom_id: 'mb_drops' }, - { type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'mb_save' }, - { type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'mb_cancel' }, - ] - } - ] + components: buildComponents() }); } catch {} } diff --git a/src/commands/messages/game/offerEdit.ts b/src/commands/messages/game/offerEdit.ts index cd21c01..56ffa54 100644 --- a/src/commands/messages/game/offerEdit.ts +++ b/src/commands/messages/game/offerEdit.ts @@ -2,8 +2,9 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { hasManageGuildOrStaff } from '../../../core/lib/permissions'; import { prisma } from '../../../core/database/prisma'; -import { Message, MessageComponentInteraction, MessageFlags, ButtonInteraction } from 'discord.js'; +import { Message, MessageComponentInteraction, MessageFlags, ButtonInteraction, TextBasedChannel } from 'discord.js'; import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10'; +import { promptKeySelection, resolveItemIcon } from './_helpers'; interface OfferState { offerId: string; @@ -23,23 +24,64 @@ export const command: CommandMessage = { aliases: ['editar-oferta','offeredit'], cooldown: 10, description: 'Edita una ShopOffer por ID con editor interactivo (price/ventanas/stock/limit).', - usage: 'offer-editar ', - run: async (message, args, _client: Amayo) => { + usage: 'offer-editar', + run: async (message, _args, _client: Amayo) => { + const channel = message.channel as TextBasedChannel & { send: Function }; const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, prisma); - if (!allowed) { await message.reply('❌ No tienes permisos de ManageGuild ni rol de staff.'); return; } - - const offerId = args[0]?.trim(); - if (!offerId) { await message.reply('Uso: `!offer-editar `'); return; } + if (!allowed) { + await (channel.send as any)({ + content: null, + flags: 32768, + components: [{ + type: 17, + accent_color: 0xFF0000, + components: [{ + type: 10, + content: '❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.' + }] + }], + reply: { messageReference: message.id } + }); + return; + } const guildId = message.guild!.id; - const offer = await prisma.shopOffer.findUnique({ where: { id: offerId } }); - if (!offer || offer.guildId !== guildId) { await message.reply('❌ Oferta no encontrada para este servidor.'); return; } + const offers = await prisma.shopOffer.findMany({ + where: { guildId }, + orderBy: [{ updatedAt: 'desc' }], + include: { item: true }, + }); - const item = await prisma.economyItem.findUnique({ where: { id: offer.itemId } }); + const selection = await promptKeySelection(message, { + entries: offers, + customIdPrefix: 'offer_edit', + title: 'Selecciona una oferta para editar', + emptyText: '⚠️ **No hay ofertas configuradas.** Usa `!offer-crear` primero.', + placeholder: 'Elige una oferta…', + filterHint: 'Filtra por item, key o estado.', + getOption: (offer) => { + const icon = resolveItemIcon(offer.item?.icon); + const itemName = offer.item?.name ?? offer.item?.key ?? 'Item sin nombre'; + const status = offer.enabled ? 'Activa' : 'Inactiva'; + const label = `${icon} ${itemName}`.trim(); + return { + value: offer.id, + label: label.slice(0, 100), + description: `${status} • ${offer.id.slice(0, 14)}…`, + keywords: [offer.id, itemName, offer.item?.key ?? '', status], + }; + }, + }); + + if (!selection.entry || !selection.panelMessage) { + return; + } + + const offer = selection.entry; const state: OfferState = { - offerId, - itemKey: item?.key, + offerId: offer.id, + itemKey: offer.item?.key, enabled: offer.enabled, price: offer.price ?? {}, startAt: offer.startAt ? new Date(offer.startAt).toISOString() : '', @@ -49,38 +91,103 @@ export const command: CommandMessage = { metadata: offer.metadata ?? {}, }; - const editorMsg = await (message.channel as any).send({ - content: `🛒 Editor de Oferta (editar): ${offerId}`, - components: [ - { type: 1, components: [ + const buildEditorDisplay = () => { + const status = state.enabled ? '✅ Activa' : '⛔ Inactiva'; + const priceInfo = state.price && Object.keys(state.price ?? {}).length ? 'Configurado' : 'Sin configurar'; + const windowInfo = state.startAt || state.endAt ? `${state.startAt || '—'} → ${state.endAt || '—'}` : 'Sin ventana'; + const limitsInfo = `Usuario: ${state.perUserLimit ?? '∞'} • Stock: ${state.stock ?? '∞'}`; + const metaInfo = state.metadata && Object.keys(state.metadata ?? {}).length ? `${Object.keys(state.metadata!).length} campos` : 'Vacío'; + return { + type: 17, + accent_color: state.enabled ? 0x00D9FF : 0x666666, + components: [ + { + type: 10, + content: `# 🛒 Editando Oferta: \`${state.offerId}\`\n**Item:** ${state.itemKey ?? '*Sin asignar*'}\n**Estado:** ${status}\n**Precio:** ${priceInfo}\n**Ventana:** ${windowInfo}\n**Límites:** ${limitsInfo}\n**Meta:** ${metaInfo}` + }, + { type: 14, divider: true }, + { + type: 10, + content: '**📋 Pasos rápidos:**\n• Base: item y estado\n• Precio: JSON de coste\n• Ventana: fechas inicio/fin\n• Límites: stock y por usuario\n• Meta: datos adicionales' + } + ] + }; + }; + + const buildEditorComponents = () => [ + buildEditorDisplay(), + { + type: 1, + components: [ { type: 2, style: ButtonStyle.Primary, label: 'Base', custom_id: 'of_base' }, { type: 2, style: ButtonStyle.Secondary, label: 'Precio (JSON)', custom_id: 'of_price' }, { type: 2, style: ButtonStyle.Secondary, label: 'Ventana', custom_id: 'of_window' }, { type: 2, style: ButtonStyle.Secondary, label: 'Límites', custom_id: 'of_limits' }, { type: 2, style: ButtonStyle.Secondary, label: 'Meta (JSON)', custom_id: 'of_meta' }, - ] }, - { type: 1, components: [ + ] + }, + { + type: 1, + components: [ { type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'of_save' }, { type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'of_cancel' }, - ] }, - ], + ] + } + ]; + + const editorMsg = selection.panelMessage; + await editorMsg.edit({ + content: null, + flags: 32768, + components: buildEditorComponents(), }); - const collector = editorMsg.createMessageComponentCollector({ time: 30*60_000, filter: (i: MessageComponentInteraction)=> i.user.id === message.author.id }); + const collector = editorMsg.createMessageComponentCollector({ time: 30 * 60_000, filter: (i: MessageComponentInteraction) => i.user.id === message.author.id }); collector.on('collect', async (i: MessageComponentInteraction) => { try { if (!i.isButton()) return; switch (i.customId) { - case 'of_cancel': await i.deferUpdate(); await editorMsg.edit({ content: '❌ Editor de Oferta cancelado.', components: [] }); collector.stop('cancel'); return; - case 'of_base': await showBaseModal(i as ButtonInteraction, state); return; - case 'of_price': await showJsonModal(i as ButtonInteraction, state, 'price', 'Precio'); return; - case 'of_window': await showWindowModal(i as ButtonInteraction, state); return; - case 'of_limits': await showLimitsModal(i as ButtonInteraction, state); return; - case 'of_meta': await showJsonModal(i as ButtonInteraction, state, 'metadata', 'Meta'); return; + case 'of_cancel': + await i.deferUpdate(); + await editorMsg.edit({ + content: null, + flags: 32768, + components: [{ + type: 17, + accent_color: 0xFF0000, + components: [{ + type: 10, + content: '**❌ Editor de Oferta cancelado.**' + }] + }] + }); + collector.stop('cancel'); + return; + case 'of_base': + await showBaseModal(i as ButtonInteraction, state, editorMsg, buildEditorComponents); + return; + case 'of_price': + await showJsonModal(i as ButtonInteraction, state, 'price', 'Precio', editorMsg, buildEditorComponents); + return; + case 'of_window': + await showWindowModal(i as ButtonInteraction, state, editorMsg, buildEditorComponents); + return; + case 'of_limits': + await showLimitsModal(i as ButtonInteraction, state, editorMsg, buildEditorComponents); + return; + case 'of_meta': + await showJsonModal(i as ButtonInteraction, state, 'metadata', 'Meta', editorMsg, buildEditorComponents); + return; case 'of_save': - if (!state.itemKey) { await i.reply({ content: '❌ Falta itemKey en Base.', flags: MessageFlags.Ephemeral }); return; } + if (!state.itemKey) { + await i.reply({ content: '❌ Falta itemKey en Base.', flags: MessageFlags.Ephemeral }); + return; + } const it = await prisma.economyItem.findFirst({ where: { key: state.itemKey, OR: [{ guildId }, { guildId: null }] }, orderBy: [{ guildId: 'desc' }] }); - if (!it) { await i.reply({ content: '❌ Item no encontrado por key.', flags: MessageFlags.Ephemeral }); return; } + if (!it) { + await i.reply({ content: '❌ Item no encontrado por key.', flags: MessageFlags.Ephemeral }); + return; + } try { await prisma.shopOffer.update({ where: { id: state.offerId }, @@ -96,7 +203,18 @@ export const command: CommandMessage = { } }); await i.reply({ content: '✅ Oferta actualizada.', flags: MessageFlags.Ephemeral }); - await editorMsg.edit({ content: `✅ Oferta ${state.offerId} actualizada.`, components: [] }); + await editorMsg.edit({ + content: null, + flags: 32768, + components: [{ + type: 17, + accent_color: 0x00FF00, + components: [{ + type: 10, + content: `✅ **Oferta \`${state.offerId}\` actualizada.**` + }] + }] + }); collector.stop('saved'); } catch (err: any) { await i.reply({ content: `❌ Error al guardar: ${err?.message ?? err}`, flags: MessageFlags.Ephemeral }); @@ -107,42 +225,115 @@ export const command: CommandMessage = { if (!i.deferred && !i.replied) await i.reply({ content: '❌ Error procesando la acción.', flags: MessageFlags.Ephemeral }); } }); - collector.on('end', async (_c: any,r: string)=> { if (r==='time') { try { await editorMsg.edit({ content:'⏰ Editor expirado.', components: [] }); } catch {} } }); + + collector.on('end', async (_c: any, reason: string) => { + if (reason === 'time') { + try { + await editorMsg.edit({ + content: null, + flags: 32768, + components: [{ + type: 17, + accent_color: 0xFFA500, + components: [{ + type: 10, + content: '**⏰ Editor expirado.**' + }] + }] + }); + } catch {} + } + }); } }; -async function showBaseModal(i: ButtonInteraction, state: OfferState) { +async function showBaseModal(i: ButtonInteraction, state: OfferState, editorMsg: Message, buildComponents: () => any[]) { const modal = { title: 'Base de Oferta', customId: 'of_base_modal', components: [ { type: ComponentType.Label, label: 'Item Key', component: { type: ComponentType.TextInput, customId: 'itemKey', style: TextInputStyle.Short, required: true, value: state.itemKey ?? '' } }, { type: ComponentType.Label, label: 'Habilitada? (true/false)', component: { type: ComponentType.TextInput, customId: 'enabled', style: TextInputStyle.Short, required: false, value: String(state.enabled ?? true) } }, ] } as const; await i.showModal(modal); - try { const sub = await i.awaitModalSubmit({ time: 300_000 }); state.itemKey = sub.components.getTextInputValue('itemKey').trim(); const en = sub.components.getTextInputValue('enabled').trim(); state.enabled = en ? (en.toLowerCase() !== 'false') : (state.enabled ?? true); await sub.reply({ content: '✅ Base actualizada.', flags: MessageFlags.Ephemeral }); } catch {} + try { + const sub = await i.awaitModalSubmit({ time: 300_000 }); + state.itemKey = sub.components.getTextInputValue('itemKey').trim(); + const en = sub.components.getTextInputValue('enabled').trim(); + state.enabled = en ? (en.toLowerCase() !== 'false') : (state.enabled ?? true); + await sub.deferUpdate(); + await editorMsg.edit({ + content: null, + flags: 32768, + components: buildComponents() + }); + } catch {} } -async function showJsonModal(i: ButtonInteraction, state: OfferState, field: 'price'|'metadata', title: string) { +async function showJsonModal(i: ButtonInteraction, state: OfferState, field: 'price'|'metadata', title: string, editorMsg: Message, buildComponents: () => any[]) { const current = JSON.stringify(state[field] ?? {}); const modal = { title, customId: `of_json_${field}`, components: [ { type: ComponentType.Label, label: 'JSON', component: { type: ComponentType.TextInput, customId: 'json', style: TextInputStyle.Paragraph, required: false, value: current.slice(0,4000) } }, ] } as const; await i.showModal(modal); - try { const sub = await i.awaitModalSubmit({ time: 300_000 }); const raw = sub.components.getTextInputValue('json'); if (raw) { try { state[field] = JSON.parse(raw); await sub.reply({ content: '✅ Guardado.', flags: MessageFlags.Ephemeral }); } catch { await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral }); } } else { state[field] = {}; await sub.reply({ content: 'ℹ️ Limpio.', flags: MessageFlags.Ephemeral }); } } catch {} + try { + const sub = await i.awaitModalSubmit({ time: 300_000 }); + const raw = sub.components.getTextInputValue('json'); + if (raw) { + try { + state[field] = JSON.parse(raw); + await sub.deferUpdate(); + } catch { + await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral }); + return; + } + } else { + state[field] = {}; + await sub.deferUpdate(); + } + await editorMsg.edit({ + content: null, + flags: 32768, + components: buildComponents() + }); + } catch {} } -async function showWindowModal(i: ButtonInteraction, state: OfferState) { +async function showWindowModal(i: ButtonInteraction, state: OfferState, editorMsg: Message, buildComponents: () => any[]) { const modal = { title: 'Ventana', customId: 'of_window_modal', components: [ { type: ComponentType.Label, label: 'Inicio (ISO opcional)', component: { type: ComponentType.TextInput, customId: 'start', style: TextInputStyle.Short, required: false, value: state.startAt ?? '' } }, { type: ComponentType.Label, label: 'Fin (ISO opcional)', component: { type: ComponentType.TextInput, customId: 'end', style: TextInputStyle.Short, required: false, value: state.endAt ?? '' } }, ] } as const; await i.showModal(modal); - try { const sub = await i.awaitModalSubmit({ time: 300_000 }); const s = sub.components.getTextInputValue('start').trim(); const e = sub.components.getTextInputValue('end').trim(); state.startAt = s || ''; state.endAt = e || ''; await sub.reply({ content: '✅ Ventana actualizada.', flags: MessageFlags.Ephemeral }); } catch {} + try { + const sub = await i.awaitModalSubmit({ time: 300_000 }); + const s = sub.components.getTextInputValue('start').trim(); + const e = sub.components.getTextInputValue('end').trim(); + state.startAt = s || ''; + state.endAt = e || ''; + await sub.deferUpdate(); + await editorMsg.edit({ + content: null, + flags: 32768, + components: buildComponents() + }); + } catch {} } -async function showLimitsModal(i: ButtonInteraction, state: OfferState) { +async function showLimitsModal(i: ButtonInteraction, state: OfferState, editorMsg: Message, buildComponents: () => any[]) { const modal = { title: 'Límites', customId: 'of_limits_modal', components: [ { type: ComponentType.Label, label: 'Límite por usuario (vacío = sin límite)', component: { type: ComponentType.TextInput, customId: 'limit', style: TextInputStyle.Short, required: false, value: state.perUserLimit != null ? String(state.perUserLimit) : '' } }, { type: ComponentType.Label, label: 'Stock global (vacío = ilimitado)', component: { type: ComponentType.TextInput, customId: 'stock', style: TextInputStyle.Short, required: false, value: state.stock != null ? String(state.stock) : '' } }, ] } as const; await i.showModal(modal); - try { const sub = await i.awaitModalSubmit({ time: 300_000 }); const lim = sub.components.getTextInputValue('limit').trim(); const st = sub.components.getTextInputValue('stock').trim(); state.perUserLimit = lim ? Math.max(0, parseInt(lim,10)||0) : null; state.stock = st ? Math.max(0, parseInt(st,10)||0) : null; await sub.reply({ content: '✅ Límites actualizados.', flags: MessageFlags.Ephemeral }); } catch {} + try { + const sub = await i.awaitModalSubmit({ time: 300_000 }); + const lim = sub.components.getTextInputValue('limit').trim(); + const st = sub.components.getTextInputValue('stock').trim(); + state.perUserLimit = lim ? Math.max(0, parseInt(lim, 10) || 0) : null; + state.stock = st ? Math.max(0, parseInt(st, 10) || 0) : null; + await sub.deferUpdate(); + await editorMsg.edit({ + content: null, + flags: 32768, + components: buildComponents() + }); + } catch {} } diff --git a/src/commands/messages/game/pelear.ts b/src/commands/messages/game/pelear.ts index fbca489..b3afe2c 100644 --- a/src/commands/messages/game/pelear.ts +++ b/src/commands/messages/game/pelear.ts @@ -1,10 +1,13 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { runMinigame } from '../../../game/minigames/service'; -import { getDefaultLevel, findBestToolKey, parseGameArgs, resolveGuildAreaWithFallback, resolveAreaByType } from './_helpers'; +import { getDefaultLevel, findBestToolKey, parseGameArgs, resolveGuildAreaWithFallback, resolveAreaByType, sendDisplayReply, fetchItemBasics, formatItemLabel } from './_helpers'; import { updateStats } from '../../../game/stats/service'; import { updateQuestProgress } from '../../../game/quests/service'; import { checkAchievements } from '../../../game/achievements/service'; +import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2'; + +const FIGHT_ACCENT = 0x992D22; export const command: CommandMessage = { name: 'pelear', @@ -41,7 +44,13 @@ export const command: CommandMessage = { try { const result = await runMinigame(userId, guildId, area.key, level, { toolKey: toolKey ?? undefined }); - + + const rewardKeys = result.rewards + .filter((r): r is { type: 'item'; itemKey: string; qty?: number } => r.type === 'item' && Boolean(r.itemKey)) + .map((r) => r.itemKey!); + if (result.tool?.key) rewardKeys.push(result.tool.key); + const rewardItems = await fetchItemBasics(guildId, rewardKeys); + // Actualizar stats y misiones await updateStats(userId, guildId, { fightsCompleted: 1 }); await updateQuestProgress(userId, guildId, 'fight_count', 1); @@ -55,21 +64,44 @@ export const command: CommandMessage = { const newAchievements = await checkAchievements(userId, guildId, 'fight_count'); - const rewards = result.rewards.map(r => r.type === 'coins' ? `🪙 +${r.amount}` : `🎁 ${r.itemKey} x${r.qty}`).join(' · ') || '—'; - const mobs = result.mobs.length ? result.mobs.join(', ') : '—'; - const toolInfo = result.tool?.key ? `🗡️ ${result.tool.key}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta} dur.)`}` : '—'; - - let response = globalNotice ? `${globalNotice}\n\n` : ''; - response += `⚔️ Arena (nivel ${level})\nRecompensas: ${rewards}\nEnemigos: ${mobs}\nArma: ${toolInfo}`; + const rewardLines = result.rewards.length + ? result.rewards.map((r) => { + if (r.type === 'coins') return `• 🪙 +${r.amount}`; + const info = rewardItems.get(r.itemKey!); + const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); + return `• ${label} x${r.qty ?? 1}`; + }).join('\n') + : '• —'; + const mobsLines = result.mobs.length + ? result.mobs.map(m => `• ${m}`).join('\n') + : '• —'; + const toolInfo = result.tool?.key + ? `${formatItemLabel(rewardItems.get(result.tool.key) ?? { key: result.tool.key, name: null, icon: null }, { fallbackIcon: '🗡️' })}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta ?? 0} dur.)`}` + : '—'; + + const blocks = [textBlock('# ⚔️ Arena')]; + + if (globalNotice) { + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(globalNotice)); + } + + blocks.push(dividerBlock()); + const areaScope = source === 'global' ? '🌐 Configuración global' : '📍 Configuración local'; + blocks.push(textBlock(`**Área:** \`${area.key}\` • ${areaScope}\n**Nivel:** ${level}\n**Arma:** ${toolInfo}`)); + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(`**Recompensas**\n${rewardLines}`)); + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(`**Enemigos**\n${mobsLines}`)); if (newAchievements.length > 0) { - response += `\n\n🏆 ¡Logro desbloqueado!`; - for (const ach of newAchievements) { - response += `\n✨ **${ach.name}** - ${ach.description}`; - } + blocks.push(dividerBlock({ divider: false, spacing: 2 })); + const achLines = newAchievements.map(ach => `✨ **${ach.name}** — ${ach.description}`).join('\n'); + blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`)); } - - await message.reply(response); + + const display = buildDisplay(FIGHT_ACCENT, blocks); + await sendDisplayReply(message, display); } catch (e: any) { await message.reply(`❌ No se pudo pelear: ${e?.message ?? e}`); } diff --git a/src/commands/messages/game/pescar.ts b/src/commands/messages/game/pescar.ts index b8d8d40..bacdd10 100644 --- a/src/commands/messages/game/pescar.ts +++ b/src/commands/messages/game/pescar.ts @@ -1,10 +1,13 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { runMinigame } from '../../../game/minigames/service'; -import { getDefaultLevel, findBestToolKey, parseGameArgs, resolveGuildAreaWithFallback, resolveAreaByType } from './_helpers'; +import { getDefaultLevel, findBestToolKey, parseGameArgs, resolveGuildAreaWithFallback, resolveAreaByType, sendDisplayReply, fetchItemBasics, formatItemLabel } from './_helpers'; import { updateStats } from '../../../game/stats/service'; import { updateQuestProgress } from '../../../game/quests/service'; import { checkAchievements } from '../../../game/achievements/service'; +import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2'; + +const FISHING_ACCENT = 0x1ABC9C; export const command: CommandMessage = { name: 'pescar', @@ -41,30 +44,56 @@ export const command: CommandMessage = { try { const result = await runMinigame(userId, guildId, area.key, level, { toolKey: toolKey ?? undefined }); - + + const rewardKeys = result.rewards + .filter((r): r is { type: 'item'; itemKey: string; qty?: number } => r.type === 'item' && Boolean(r.itemKey)) + .map((r) => r.itemKey!); + if (result.tool?.key) rewardKeys.push(result.tool.key); + const rewardItems = await fetchItemBasics(guildId, rewardKeys); + // Actualizar stats y misiones await updateStats(userId, guildId, { fishingCompleted: 1 }); await updateQuestProgress(userId, guildId, 'fish_count', 1); const newAchievements = await checkAchievements(userId, guildId, 'fish_count'); - - const rewards = result.rewards.map(r => r.type === 'coins' ? `🪙 +${r.amount}` : `🐟 ${r.itemKey} x${r.qty}`).join(' · ') || '—'; - const mobs = result.mobs.length ? result.mobs.join(', ') : '—'; - const toolInfo = result.tool?.key ? `🎣 ${result.tool.key}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta} dur.)`}` : '—'; - - let response = globalNotice ? `${globalNotice}\n\n` : ''; - response += `🎣 Pesca (nivel ${level}) -Recompensas: ${rewards} -Mobs: ${mobs} -Herramienta: ${toolInfo}`; + + const rewardLines = result.rewards.length + ? result.rewards.map((r) => { + if (r.type === 'coins') return `• 🪙 +${r.amount}`; + const info = rewardItems.get(r.itemKey!); + const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); + return `• ${label} x${r.qty ?? 1}`; + }).join('\n') + : '• —'; + const mobsLines = result.mobs.length + ? result.mobs.map(m => `• ${m}`).join('\n') + : '• —'; + const toolInfo = result.tool?.key + ? `${formatItemLabel(rewardItems.get(result.tool.key) ?? { key: result.tool.key, name: null, icon: null }, { fallbackIcon: '🎣' })}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta ?? 0} dur.)`}` + : '—'; + + const blocks = [textBlock('# 🎣 Pesca')]; + + if (globalNotice) { + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(globalNotice)); + } + + blocks.push(dividerBlock()); + const areaScope = source === 'global' ? '🌐 Configuración global' : '📍 Configuración local'; + blocks.push(textBlock(`**Área:** \`${area.key}\` • ${areaScope}\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`)); + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(`**Recompensas**\n${rewardLines}`)); + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(`**Mobs**\n${mobsLines}`)); if (newAchievements.length > 0) { - response += `\n\n🏆 ¡Logro desbloqueado!`; - for (const ach of newAchievements) { - response += `\n✨ **${ach.name}** - ${ach.description}`; - } + blocks.push(dividerBlock({ divider: false, spacing: 2 })); + const achLines = newAchievements.map(ach => `✨ **${ach.name}** — ${ach.description}`).join('\n'); + blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`)); } - - await message.reply(response); + + const display = buildDisplay(FISHING_ACCENT, blocks); + await sendDisplayReply(message, display); } catch (e: any) { await message.reply(`❌ No se pudo pescar: ${e?.message ?? e}`); } diff --git a/src/commands/messages/game/plantar.ts b/src/commands/messages/game/plantar.ts index 021ebe0..915752c 100644 --- a/src/commands/messages/game/plantar.ts +++ b/src/commands/messages/game/plantar.ts @@ -1,7 +1,10 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { runMinigame } from '../../../game/minigames/service'; -import { resolveArea, getDefaultLevel, findBestToolKey } from './_helpers'; +import { resolveArea, getDefaultLevel, findBestToolKey, sendDisplayReply, fetchItemBasics, formatItemLabel } from './_helpers'; +import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2'; + +const FARM_ACCENT = 0x2ECC71; export const command: CommandMessage = { name: 'plantar', @@ -26,13 +29,40 @@ export const command: CommandMessage = { try { const result = await runMinigame(userId, guildId, areaKey, level, { toolKey: toolKey ?? undefined }); - const rewards = result.rewards.map(r => r.type === 'coins' ? `🪙 +${r.amount}` : `🌾 ${r.itemKey} x${r.qty}`).join(' · ') || '—'; - const mobs = result.mobs.length ? result.mobs.join(', ') : '—'; - const toolInfo = result.tool?.key ? `🪓 ${result.tool.key}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta} dur.)`}` : '—'; - await message.reply(`🌱 Campo (nivel ${level}) -Recompensas: ${rewards} -Eventos: ${mobs} -Herramienta: ${toolInfo}`); + + const rewardKeys = result.rewards + .filter((r): r is { type: 'item'; itemKey: string; qty?: number } => r.type === 'item' && Boolean(r.itemKey)) + .map((r) => r.itemKey!); + if (result.tool?.key) rewardKeys.push(result.tool.key); + const rewardItems = await fetchItemBasics(guildId, rewardKeys); + + const rewardLines = result.rewards.length + ? result.rewards.map((r) => { + if (r.type === 'coins') return `• 🪙 +${r.amount}`; + const info = rewardItems.get(r.itemKey!); + const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); + return `• ${label} x${r.qty ?? 1}`; + }).join('\n') + : '• —'; + const mobsLines = result.mobs.length + ? result.mobs.map(m => `• ${m}`).join('\n') + : '• —'; + const toolInfo = result.tool?.key + ? `${formatItemLabel(rewardItems.get(result.tool.key) ?? { key: result.tool.key, name: null, icon: null }, { fallbackIcon: '🪓' })}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta ?? 0} dur.)`}` + : '—'; + + const blocks = [ + textBlock('# 🌱 Campo'), + dividerBlock(), + textBlock(`**Área:** \`${area.key}\`\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`), + dividerBlock({ divider: false, spacing: 1 }), + textBlock(`**Recompensas**\n${rewardLines}`), + dividerBlock({ divider: false, spacing: 1 }), + textBlock(`**Eventos**\n${mobsLines}`), + ]; + + const display = buildDisplay(FARM_ACCENT, blocks); + await sendDisplayReply(message, display); } catch (e: any) { await message.reply(`❌ No se pudo plantar: ${e?.message ?? e}`); } diff --git a/src/commands/messages/game/player.ts b/src/commands/messages/game/player.ts index d1d956d..d4aa414 100644 --- a/src/commands/messages/game/player.ts +++ b/src/commands/messages/game/player.ts @@ -5,6 +5,7 @@ import { getOrCreateWallet } from '../../../game/economy/service'; import { getEquipment, getEffectiveStats } from '../../../game/combat/equipmentService'; import { getPlayerStatsFormatted } from '../../../game/stats/service'; import type { TextBasedChannel } from 'discord.js'; +import { formatItemLabel } from './_helpers'; export const command: CommandMessage = { name: 'player', @@ -49,6 +50,16 @@ export const command: CommandMessage = { take: 3, }); + const weaponLine = weapon + ? `⚔️ Arma: ${formatItemLabel(weapon, { fallbackIcon: '🗡️', bold: true })}` + : '⚔️ Arma: *Ninguna*'; + const armorLine = armor + ? `🛡️ Armadura: ${formatItemLabel(armor, { fallbackIcon: '🛡️', bold: true })}` + : '🛡️ Armadura: *Ninguna*'; + const capeLine = cape + ? `🧥 Capa: ${formatItemLabel(cape, { fallbackIcon: '🧥', bold: true })}` + : '🧥 Capa: *Ninguna*'; + // Crear DisplayComponent const display = { type: 17, @@ -71,9 +82,9 @@ export const command: CommandMessage = { { type: 10, content: `**⚔️ EQUIPO**\n` + - (weapon ? `🗡️ Arma: **${weapon.name || weapon.key}**\n` : '🗡️ Arma: *Ninguna*\n') + - (armor ? `🛡️ Armadura: **${armor.name || armor.key}**\n` : '🛡️ Armadura: *Ninguna*\n') + - (cape ? `🧥 Capa: **${cape.name || cape.key}**` : '🧥 Capa: *Ninguna*') + `${weaponLine}\n` + + `${armorLine}\n` + + `${capeLine}` }, { type: 14, divider: true }, { diff --git a/src/commands/messages/game/racha.ts b/src/commands/messages/game/racha.ts index 3eeb174..5e85f02 100644 --- a/src/commands/messages/game/racha.ts +++ b/src/commands/messages/game/racha.ts @@ -2,6 +2,7 @@ import type { CommandMessage } from '../../../core/types/commands'; import type Amayo from '../../../core/client'; import { getStreakInfo, updateStreak } from '../../../game/streaks/service'; import type { TextBasedChannel } from 'discord.js'; +import { fetchItemBasics, formatItemLabel } from './_helpers'; export const command: CommandMessage = { name: 'racha', @@ -62,9 +63,12 @@ export const command: CommandMessage = { if (rewards) { let rewardsText = '**🎁 RECOMPENSA DEL DÍA**\n'; if (rewards.coins) rewardsText += `💰 **${rewards.coins.toLocaleString()}** monedas\n`; - if (rewards.items) { + if (rewards.items && rewards.items.length) { + const basics = await fetchItemBasics(guildId, rewards.items.map((item) => item.key)); rewards.items.forEach(item => { - rewardsText += `📦 **${item.quantity}x** ${item.key}\n`; + const info = basics.get(item.key) ?? { key: item.key, name: null, icon: null }; + const label = formatItemLabel(info, { bold: true }); + rewardsText += `${label} ×${item.quantity}\n`; }); } diff --git a/src/commands/messages/game/tienda.ts b/src/commands/messages/game/tienda.ts index 6cc8f5a..b9e2528 100644 --- a/src/commands/messages/game/tienda.ts +++ b/src/commands/messages/game/tienda.ts @@ -13,6 +13,7 @@ import { prisma } from '../../../core/database/prisma'; import { getOrCreateWallet, buyFromOffer } from '../../../game/economy/service'; import type { DisplayComponentContainer } from '../../../core/types/displayComponents'; import type { ItemProps } from '../../../game/economy/types'; +import { formatItemLabel, resolveItemIcon } from './_helpers'; const ITEMS_PER_PAGE = 5; @@ -196,9 +197,9 @@ async function buildShopPanel( // Si hay una oferta seleccionada, mostrar detalles if (selectedOffer) { - const item = selectedOffer.item; - const props = parseItemProps(item.props); - const icon = getItemIcon(props, item.category); + const item = selectedOffer.item; + const props = parseItemProps(item.props); + const label = formatItemLabel(item, { fallbackIcon: getItemIcon(props, item.category), bold: true }); const price = formatPrice(selectedOffer.price); // Stock info @@ -225,7 +226,7 @@ async function buildShopPanel( container.components.push({ type: 10, - content: `${icon} **${item.name || item.key}**\n\n${item.description || 'Sin descripción'}${statsInfo}\n\n💰 Precio: ${price}${stockInfo}` + content: `${label}\n\n${item.description || 'Sin descripción'}${statsInfo}\n\n💰 Precio: ${price}${stockInfo}` }); container.components.push({ @@ -244,7 +245,7 @@ async function buildShopPanel( for (const offer of pageOffers) { const item = offer.item; const props = parseItemProps(item.props); - const icon = getItemIcon(props, item.category); + const label = formatItemLabel(item, { fallbackIcon: getItemIcon(props, item.category), bold: true }); const price = formatPrice(offer.price); const isSelected = selectedOfferId === offer.id; @@ -255,7 +256,7 @@ async function buildShopPanel( type: 9, components: [{ type: 10, - content: `${icon} **${item.name || item.key}**${selectedMark}\n💰 ${price}${stockText}` + content: `${label}${selectedMark}\n💰 ${price}${stockText}` }], accessory: { type: 2, @@ -370,8 +371,9 @@ async function handleButtonInteraction( const result = await buyFromOffer(userId, guildId, selectedOfferId, qty); const wallet = await getOrCreateWallet(userId, guildId); + const purchaseLabel = formatItemLabel(result.item, { fallbackIcon: resolveItemIcon(result.item.icon) }); await interaction.followUp({ - content: `✅ **Compra exitosa!**\n🛒 ${result.item.name || result.item.key} x${result.qty}\n💰 Te quedan: ${wallet.coins} monedas`, + content: `✅ **Compra exitosa!**\n🛒 ${purchaseLabel} x${result.qty}\n💰 Te quedan: ${wallet.coins} monedas`, flags: MessageFlags.Ephemeral });