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.
This commit is contained in:
2025-10-05 21:37:54 -05:00
parent 0c87ead245
commit cdcc339693
20 changed files with 1192 additions and 323 deletions

View File

@@ -1,6 +1,16 @@
import { prisma } from '../../../core/database/prisma'; import { prisma } from '../../../core/database/prisma';
import type { GameArea } from '@prisma/client'; import type { GameArea } from '@prisma/client';
import type { ItemProps } from '../../../game/economy/types'; 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 { export function parseItemProps(json: unknown): ItemProps {
if (!json || typeof json !== 'object') return {}; if (!json || typeof json !== 'object') return {};
@@ -99,3 +109,396 @@ export function parseGameArgs(args: string[]): ParsedGameArgs {
return { levelArg, providedTool, areaOverride }; 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<Map<string, ItemBasicInfo>> {
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<string, ItemBasicInfo>();
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<T> {
entries: T[];
getOption: (entry: T) => KeyPickerOption;
title: string;
customIdPrefix: string;
emptyText: string;
placeholder?: string;
filterHint?: string;
accentColor?: number;
userId?: string;
}
export interface KeyPickerResult<T> {
entry: T | null;
panelMessage: Message | null;
reason: 'selected' | 'empty' | 'cancelled' | 'timeout';
}
export async function promptKeySelection<T>(
message: Message,
config: KeyPickerConfig<T>
): Promise<KeyPickerResult<T>> {
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<KeyPickerResult<T>>((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],
});
}

View File

@@ -1,6 +1,9 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { openChestByKey } from '../../../game/economy/service'; 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 = { export const command: CommandMessage = {
name: 'abrir', name: 'abrir',
@@ -12,10 +15,50 @@ export const command: CommandMessage = {
run: async (message, args, _client: Amayo) => { run: async (message, args, _client: Amayo) => {
const itemKey = args[0]?.trim(); const itemKey = args[0]?.trim();
if (!itemKey) { await message.reply('Uso: `!abrir <itemKey>`'); return; } if (!itemKey) { await message.reply('Uso: `!abrir <itemKey>`'); return; }
const userId = message.author.id;
const guildId = message.guild!.id;
try { 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<string, ItemBasicInfo>();
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<string, ItemBasicInfo>();
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 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 rolesGiven: string[] = [];
let rolesFailed: string[] = []; let rolesFailed: string[] = [];
if (res.rolesToGrant.length && message.member) { if (res.rolesToGrant.length && message.member) {
@@ -24,7 +67,7 @@ export const command: CommandMessage = {
} }
} }
const lines = [ const lines = [
`🎁 Abriste ${itemKey}${res.consumed ? ' (consumido 1)' : ''}`, `🎁 Abriste ${chestLabel}${res.consumed ? ' (consumido 1)' : ''}`,
coins && `Monedas: ${coins}`, coins && `Monedas: ${coins}`,
items && `Ítems: ${items}`, items && `Ítems: ${items}`,
rolesGiven.length ? `Roles otorgados: ${rolesGiven.map(id=>`<@&${id}>`).join(', ')}` : '', rolesGiven.length ? `Roles otorgados: ${rolesGiven.map(id=>`<@&${id}>`).join(', ')}` : '',

View File

@@ -4,6 +4,7 @@ import { hasManageGuildOrStaff } from '../../../core/lib/permissions';
import { prisma } from '../../../core/database/prisma'; import { prisma } from '../../../core/database/prisma';
import { Message, MessageComponentInteraction, MessageFlags, ButtonInteraction, TextBasedChannel } from 'discord.js'; import { Message, MessageComponentInteraction, MessageFlags, ButtonInteraction, TextBasedChannel } from 'discord.js';
import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10'; import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10';
import { promptKeySelection } from './_helpers';
interface AreaState { interface AreaState {
key: string; key: string;
@@ -74,7 +75,7 @@ export const command: CommandMessage = {
aliases: ['editar-area','areaedit'], aliases: ['editar-area','areaedit'],
cooldown: 10, cooldown: 10,
description: 'Edita una GameArea de este servidor con un editor interactivo.', description: 'Edita una GameArea de este servidor con un editor interactivo.',
usage: 'area-editar <key-única>', usage: 'area-editar',
run: async (message, args, _client: Amayo) => { run: async (message, args, _client: Amayo) => {
const channel = message.channel as TextBasedChannel & { send: Function }; const channel = message.channel as TextBasedChannel & { send: Function };
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, prisma); const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, prisma);
@@ -95,50 +96,35 @@ export const command: CommandMessage = {
return; 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 <key-única>`'
}]
}],
reply: { messageReference: message.id }
});
return;
}
const guildId = message.guild!.id; const guildId = message.guild!.id;
const area = await prisma.gameArea.findFirst({ where: { key, guildId } }); const areas = await prisma.gameArea.findMany({ where: { guildId }, orderBy: [{ key: 'asc' }] });
if (!area) { const selection = await promptKeySelection(message, {
await (channel.send as any)({ entries: areas,
content: null, customIdPrefix: 'area_edit',
flags: 32768, title: 'Selecciona un área para editar',
components: [{ emptyText: '⚠️ **No hay áreas configuradas.** Usa `!area-crear` para crear una nueva.',
type: 17, placeholder: 'Elige un área…',
accent_color: 0xFF0000, filterHint: 'Puedes filtrar por nombre, key o tipo.',
components: [{ getOption: (area) => ({
type: 10, value: area.id,
content: '❌ **Área No Encontrada**\n└ No existe un área con esa key en este servidor.' label: `${area.name ?? area.key} (${area.type})`,
}] description: area.key,
}], keywords: [area.key, area.name ?? '', area.type ?? ''],
reply: { messageReference: message.id } }),
}); });
if (!selection.entry || !selection.panelMessage) {
return; 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, content: null,
flags: 32768, flags: 32768,
components: buildEditorComponents(state, true), components: buildEditorComponents(state, true),
reply: { messageReference: message.id }
}); });
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 });

View File

@@ -1,6 +1,7 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { useConsumableByKey } from '../../../game/consumables/service'; import { useConsumableByKey } from '../../../game/consumables/service';
import { fetchItemBasics, formatItemLabel } from './_helpers';
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'comer', name: 'comer',
@@ -12,11 +13,19 @@ export const command: CommandMessage = {
run: async (message, args, _client: Amayo) => { run: async (message, args, _client: Amayo) => {
const itemKey = args[0]?.trim(); const itemKey = args[0]?.trim();
if (!itemKey) { await message.reply('Uso: `!comer <itemKey>`'); return; } if (!itemKey) { await message.reply('Uso: `!comer <itemKey>`'); 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 { try {
const res = await useConsumableByKey(message.author.id, message.guild!.id, itemKey); const basics = await fetchItemBasics(guildId, [itemKey]);
await message.reply(`🍽️ Usaste ${itemKey}. Curado: +${res.healed} HP.`); 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) { } 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}`);
} }
} }
}; };

View File

@@ -1,6 +1,7 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { buyFromOffer } from '../../../game/economy/service'; import { buyFromOffer } from '../../../game/economy/service';
import { formatItemLabel } from './_helpers';
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'comprar', name: 'comprar',
@@ -15,7 +16,8 @@ export const command: CommandMessage = {
if (!offerId) { await message.reply('Uso: `!comprar <offerId> [qty]`'); return; } if (!offerId) { await message.reply('Uso: `!comprar <offerId> [qty]`'); return; }
try { try {
const res = await buyFromOffer(message.author.id, message.guild!.id, offerId, qty); 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) { } catch (e: any) {
await message.reply(`❌ No se pudo comprar: ${e?.message ?? e}`); await message.reply(`❌ No se pudo comprar: ${e?.message ?? e}`);
} }

View File

@@ -1,6 +1,7 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { craftByProductKey } from '../../../game/economy/service'; import { craftByProductKey } from '../../../game/economy/service';
import { fetchItemBasics, formatItemLabel } from './_helpers';
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'craftear', name: 'craftear',
@@ -14,21 +15,33 @@ export const command: CommandMessage = {
const times = Math.max(1, parseInt(args[1] || '1', 10) || 1); const times = Math.max(1, parseInt(args[1] || '1', 10) || 1);
if (!productKey) { await message.reply('Uso: `!craftear <productKey> [veces]`'); return; } if (!productKey) { await message.reply('Uso: `!craftear <productKey> [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 crafted = 0;
let lastError: any = null; let lastError: any = null;
for (let i = 0; i < times; i++) { for (let i = 0; i < times; i++) {
try { try {
const res = await craftByProductKey(message.author.id, message.guild!.id, productKey); const res = await craftByProductKey(userId, guildId, productKey);
crafted += res.added; crafted += res.added;
itemInfo = { key: res.product.key, name: res.product.name, icon: res.product.icon };
} catch (e: any) { } catch (e: any) {
lastError = e; break; lastError = e; break;
} }
} }
const label = formatItemLabel(itemInfo, { bold: true });
if (crafted > 0) { if (crafted > 0) {
await message.reply(`🛠️ Crafteado ${productKey} x${crafted * 1}.`); await message.reply(`🛠️ Crafteado ${label} x${crafted}.`);
} else { } 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'}`);
} }
} }
}; };

View File

@@ -1,6 +1,7 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { applyMutationToInventory } from '../../../game/mutations/service'; import { applyMutationToInventory } from '../../../game/mutations/service';
import { fetchItemBasics, formatItemLabel } from './_helpers';
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'encantar', name: 'encantar',
@@ -13,11 +14,19 @@ export const command: CommandMessage = {
const itemKey = args[0]?.trim(); const itemKey = args[0]?.trim();
const mutationKey = args[1]?.trim(); const mutationKey = args[1]?.trim();
if (!itemKey || !mutationKey) { await message.reply('Uso: `!encantar <itemKey> <mutationKey>`'); return; } if (!itemKey || !mutationKey) { await message.reply('Uso: `!encantar <itemKey> <mutationKey>`'); 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 { try {
await applyMutationToInventory(message.author.id, message.guild!.id, itemKey, mutationKey); const basics = await fetchItemBasics(guildId, [itemKey]);
await message.reply(`✨ Aplicada mutación ${mutationKey} a ${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) { } 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}`);
} }
} }
}; };

View File

@@ -2,6 +2,7 @@ import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { setEquipmentSlot } from '../../../game/combat/equipmentService'; import { setEquipmentSlot } from '../../../game/combat/equipmentService';
import { prisma } from '../../../core/database/prisma'; import { prisma } from '../../../core/database/prisma';
import { formatItemLabel } from './_helpers';
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'equipar', 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' }] }); 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; } 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 } } }); 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 setEquipmentSlot(userId, guildId, slot, item.id);
await message.reply(`🧰 Equipado en ${slot}: ${item.key}`); await message.reply(`🧰 Equipado en ${slot}: ${label}`);
} }
}; };

View File

@@ -4,6 +4,8 @@ import { prisma } from '../../../core/database/prisma';
import { getOrCreateWallet } from '../../../game/economy/service'; import { getOrCreateWallet } from '../../../game/economy/service';
import { getEquipment, getEffectiveStats } from '../../../game/combat/equipmentService'; import { getEquipment, getEffectiveStats } from '../../../game/combat/equipmentService';
import type { ItemProps } from '../../../game/economy/types'; import type { ItemProps } from '../../../game/economy/types';
import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2';
import { sendDisplayReply, formatItemLabel } from './_helpers';
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
@@ -28,6 +30,8 @@ function fmtStats(props: ItemProps) {
return parts.length ? ` (${parts.join(' ')})` : ''; return parts.length ? ` (${parts.join(' ')})` : '';
} }
const INVENTORY_ACCENT = 0xFEE75C;
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'inventario', name: 'inventario',
type: 'message', type: 'message',
@@ -60,15 +64,23 @@ export const command: CommandMessage = {
const tool = fmtTool(props); const tool = fmtTool(props);
const st = fmtStats(props); const st = fmtStats(props);
const tags = (itemRow.tags || []).join(', '); const tags = (itemRow.tags || []).join(', ');
await message.reply([ const detailLines = [
`📦 ${itemRow.name || itemRow.key} x${qty}`, `**Cantidad:** x${qty}`,
`Key: ${itemRow.key}`, `**Key:** \`${itemRow.key}\``,
itemRow.category ? `Categoría: ${itemRow.category}` : '', itemRow.category ? `**Categoría:** ${itemRow.category}` : '',
tags ? `Tags: ${tags}` : '', tags ? `**Tags:** ${tags}` : '',
tool ? `Herramienta: ${tool}` : '', tool ? `**Herramienta:** ${tool}` : '',
st ? `Bonos: ${st}` : '', st ? `**Bonos:** ${st}` : '',
props.craftingOnly ? 'Solo crafteo' : '', props.craftingOnly ? '⚠️ Solo crafteo' : '',
].filter(Boolean).join('\n')); ].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; return;
} }
} }
@@ -88,37 +100,56 @@ export const command: CommandMessage = {
.sort((a, b) => (b.quantity - a.quantity) || a.item.key.localeCompare(b.item.key)) .sort((a, b) => (b.quantity - a.quantity) || a.item.key.localeCompare(b.item.key))
.slice(start, start + PAGE_SIZE); .slice(start, start + PAGE_SIZE);
const lines: string[] = [];
// header con saldo y equipo
lines.push(`💰 Monedas: ${wallet.coins}`);
const gear: string[] = []; const gear: string[] = [];
if (weapon) gear.push(`🗡️ ${weapon.key}`); if (weapon) gear.push(`🗡️ ${formatItemLabel(weapon, { fallbackIcon: '' })}`);
if (armor) gear.push(`🛡️ ${armor.key}`); if (armor) gear.push(`🛡️ ${formatItemLabel(armor, { fallbackIcon: '' })}`);
if (cape) gear.push(`🧥 ${cape.key}`); if (cape) gear.push(`🧥 ${formatItemLabel(cape, { fallbackIcon: '' })}`);
if (gear.length) lines.push(`🧰 Equipo: ${gear.join(' · ')}`); const headerLines = [
lines.push(`❤️ HP: ${stats.hp}/${stats.maxHp} · ⚔️ ATK: ${stats.damage} · 🛡️ DEF: ${stats.defense}`); `💰 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) { if (!pageItems.length) {
lines.push(filter ? `No hay ítems que coincidan con "${filter}".` : 'No tienes ítems en tu inventario.'); blocks.push(dividerBlock({ divider: false, spacing: 1 }));
await message.reply(lines.join('\n')); 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; 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) { pageItems.forEach((entry, index) => {
const p = parseItemProps(e.item.props); const props = parseItemProps(entry.item.props);
const tool = fmtTool(p); const tool = fmtTool(props);
const st = fmtStats(p); const st = fmtStats(props);
const name = e.item.name || e.item.key; const label = formatItemLabel(entry.item);
lines.push(`${name} — x${e.quantity}${tool ? ` ${tool}` : ''}${st}`); 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) { 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);
} }
}; };

View File

@@ -4,6 +4,7 @@ import type { CommandMessage } from '../../../core/types/commands';
import { hasManageGuildOrStaff } from '../../../core/lib/permissions'; import { hasManageGuildOrStaff } from '../../../core/lib/permissions';
import logger from '../../../core/lib/logger'; import logger from '../../../core/lib/logger';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { promptKeySelection, resolveItemIcon } from './_helpers';
interface ItemEditorState { interface ItemEditorState {
key: string; key: string;
@@ -24,8 +25,8 @@ export const command: CommandMessage = {
cooldown: 10, cooldown: 10,
description: 'Edita un EconomyItem existente del servidor con un pequeño editor interactivo.', description: 'Edita un EconomyItem existente del servidor con un pequeño editor interactivo.',
category: 'Economía', category: 'Economía',
usage: 'item-editar <key-única>', usage: 'item-editar',
run: async (message: Message, args: string[], client: Amayo) => { run: async (message: Message, _args: string[], client: Amayo) => {
const channel = message.channel as TextBasedChannel & { send: Function }; const channel = message.channel as TextBasedChannel & { send: Function };
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, client.prisma); const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, client.prisma);
if (!allowed) { if (!allowed) {
@@ -45,53 +46,43 @@ export const command: CommandMessage = {
return; 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 <key-única>`'
}]
}],
reply: { messageReference: message.id }
});
return;
}
const guildId = message.guild!.id; const guildId = message.guild!.id;
const items = await client.prisma.economyItem.findMany({ where: { guildId }, orderBy: [{ key: 'asc' }] });
const existing = await client.prisma.economyItem.findFirst({ where: { key, guildId } }); const selection = await promptKeySelection(message, {
if (!existing) { entries: items,
await (channel.send as any)({ customIdPrefix: 'item_edit',
content: null, title: 'Selecciona un ítem para editar',
flags: 32768, emptyText: '⚠️ **No hay ítems locales configurados.** Usa `!item-crear` primero.',
components: [{ placeholder: 'Elige un ítem…',
type: 17, filterHint: 'Filtra por nombre, key, categoría o tag.',
accent_color: 0xFF0000, getOption: (item) => {
components: [{ const icon = resolveItemIcon(item.icon);
type: 10, const label = `${icon} ${(item.name ?? item.key)}`.trim();
content: '❌ **Item No Encontrado**\n└ No existe un item con esa key en este servidor.' const tags = Array.isArray(item.tags) ? item.tags : [];
}] return {
}], value: item.id,
reply: { messageReference: message.id } label: label.slice(0, 100),
description: item.key,
keywords: [item.key, item.name ?? '', item.category ?? '', ...tags],
};
},
}); });
if (!selection.entry || !selection.panelMessage) {
return; return;
} }
const existing = selection.entry;
const state: ItemEditorState = { const state: ItemEditorState = {
key, key: existing.key,
name: existing.name, name: existing.name,
description: existing.description || undefined, description: existing.description || undefined,
category: existing.category || undefined, category: existing.category || undefined,
icon: existing.icon || undefined, icon: existing.icon || undefined,
stackable: existing.stackable ?? true, stackable: existing.stackable ?? true,
maxPerInventory: existing.maxPerInventory || null, maxPerInventory: existing.maxPerInventory ?? null,
tags: existing.tags || [], tags: Array.isArray(existing.tags) ? existing.tags : [],
props: existing.props || {}, props: existing.props || {},
}; };
@@ -114,7 +105,7 @@ export const command: CommandMessage = {
components: [ components: [
{ {
type: 10, type: 10,
content: `# 🛠️ Editando Item: \`${key}\`` content: `# 🛠️ Editando Item: \`${state.key}\``
}, },
{ type: 14, divider: true }, { 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, content: null,
flags: 32768, flags: 32768,
components: buildEditorComponents(), components: buildEditorComponents(),
reply: { messageReference: message.id }
}); });
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 });

View File

@@ -1,10 +1,13 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { runMinigame } from '../../../game/minigames/service'; 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 { updateStats } from '../../../game/stats/service';
import { updateQuestProgress } from '../../../game/quests/service'; import { updateQuestProgress } from '../../../game/quests/service';
import { checkAchievements } from '../../../game/achievements/service'; import { checkAchievements } from '../../../game/achievements/service';
import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2';
const MINING_ACCENT = 0xC27C0E;
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'mina', name: 'mina',
@@ -42,6 +45,12 @@ export const command: CommandMessage = {
try { try {
const result = await runMinigame(userId, guildId, area.key, level, { toolKey: toolKey ?? undefined }); 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 // Actualizar stats
await updateStats(userId, guildId, { minesCompleted: 1 }); await updateStats(userId, guildId, { minesCompleted: 1 });
@@ -51,25 +60,44 @@ export const command: CommandMessage = {
// Verificar logros // Verificar logros
const newAchievements = await checkAchievements(userId, guildId, 'mine_count'); 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 rewardLines = result.rewards.length
const mobs = result.mobs.length ? result.mobs.join(', ') : '—'; ? result.rewards.map((r) => {
const toolInfo = result.tool?.key ? `🔧 ${result.tool.key}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta} dur.)`}` : '—'; 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.)`}`
: '—';
let response = globalNotice ? `${globalNotice}\n\n` : ''; const blocks = [textBlock('# ⛏️ Mina')];
response += `⛏️ Mina (nivel ${level})
Recompensas: ${rewards} if (globalNotice) {
Mobs: ${mobs} blocks.push(dividerBlock({ divider: false, spacing: 1 }));
Herramienta: ${toolInfo}`; 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}`));
// Notificar logros desbloqueados
if (newAchievements.length > 0) { if (newAchievements.length > 0) {
response += `\n\n🏆 ¡Logro desbloqueado!`; blocks.push(dividerBlock({ divider: false, spacing: 2 }));
for (const ach of newAchievements) { const achLines = newAchievements.map(ach => `✨ **${ach.name}** — ${ach.description}`).join('\n');
response += `\n✨ **${ach.name}** - ${ach.description}`; blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`));
}
} }
await message.reply(response); const display = buildDisplay(MINING_ACCENT, blocks);
await sendDisplayReply(message, display);
} catch (e: any) { } catch (e: any) {
await message.reply(`❌ No se pudo minar: ${e?.message ?? e}`); await message.reply(`❌ No se pudo minar: ${e?.message ?? e}`);
} }

View File

@@ -2,6 +2,7 @@ import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { claimQuestReward, getPlayerQuests } from '../../../game/quests/service'; import { claimQuestReward, getPlayerQuests } from '../../../game/quests/service';
import { EmbedBuilder } from 'discord.js'; import { EmbedBuilder } from 'discord.js';
import { fetchItemBasics, formatItemLabel } from './_helpers';
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'mision-reclamar', name: 'mision-reclamar',
@@ -41,6 +42,21 @@ export const command: CommandMessage = {
// Reclamar recompensa // Reclamar recompensa
const { quest, rewards } = await claimQuestReward(userId, guildId, selected.quest.id); 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() const embed = new EmbedBuilder()
.setColor(0x00FF00) .setColor(0x00FF00)
.setTitle('🎉 ¡Misión Completada!') .setTitle('🎉 ¡Misión Completada!')
@@ -48,10 +64,10 @@ export const command: CommandMessage = {
.setThumbnail(message.author.displayAvatarURL({ size: 128 })); .setThumbnail(message.author.displayAvatarURL({ size: 128 }));
// Mostrar recompensas // Mostrar recompensas
if (rewards.length > 0) { if (rewardsDisplay.length > 0) {
embed.addFields({ embed.addFields({
name: '🎁 Recompensas Recibidas', name: '🎁 Recompensas Recibidas',
value: rewards.join('\n'), value: rewardsDisplay.join('\n'),
inline: false inline: false
}); });
} }

View File

@@ -4,6 +4,7 @@ import type { CommandMessage } from '../../../core/types/commands';
import { hasManageGuildOrStaff } from '../../../core/lib/permissions'; import { hasManageGuildOrStaff } from '../../../core/lib/permissions';
import logger from '../../../core/lib/logger'; import logger from '../../../core/lib/logger';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { promptKeySelection } from './_helpers';
interface MobEditorState { interface MobEditorState {
key: string; key: string;
@@ -64,112 +65,60 @@ export const command: CommandMessage = {
cooldown: 10, cooldown: 10,
description: 'Edita un Mob (enemigo) de este servidor con editor interactivo.', description: 'Edita un Mob (enemigo) de este servidor con editor interactivo.',
category: 'Minijuegos', category: 'Minijuegos',
usage: 'mob-editar <key-única>', usage: 'mob-editar',
run: async (message: Message, args: string[], client: Amayo) => { 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); 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; } if (!allowed) {
const key = args[0]?.trim(); await (channel.send as any)({
if (!key) { await message.reply('Uso: `!mob-editar <key-única>`'); return; } content: null,
const guildId = message.guild!.id; 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 } }); const guildId = message.guild!.id;
if (!mob) { await message.reply('❌ No existe un mob con esa key en este servidor.'); return; } 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 = { const state: MobEditorState = {
key, key: mob.key,
name: mob.name, name: mob.name,
category: mob.category ?? undefined, category: mob.category ?? undefined,
stats: mob.stats ?? {}, stats: mob.stats ?? {},
drops: mob.drops ?? {}, drops: mob.drops ?? {},
}; };
const channel = message.channel as TextBasedChannel & { send: Function }; const buildEditorComponents = () => [
const editorMsg = await channel.send({ createMobDisplay(state, true),
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 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,
components: [{
type: 9,
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,
components: [{
type: 9,
components: [{
type: 10,
content: `**✅ Mob \`${state.key}\` actualizado exitosamente.**`
}]
}]
}]
});
collector.stop('saved');
return;
}
} catch (err) {
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 {} } });
},
};
async function showBaseModal(i: ButtonInteraction, state: MobEditorState, editorMsg: Message, editing: boolean) {
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 ?? '' } },
] } as const;
await i.showModal(modal);
try {
const sub = await i.awaitModalSubmit({ time: 300_000 });
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 editorMsg.edit({
flags: 32768,
components: [
newDisplay,
{ {
type: 1, type: 1,
components: [ components: [
@@ -180,12 +129,114 @@ async function showBaseModal(i: ButtonInteraction, state: MobEditorState, editor
{ type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'mb_cancel' }, { 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 });
collector.on('collect', async (i: MessageComponentInteraction) => {
try {
if (!i.isButton()) return;
switch (i.customId) {
case 'mb_cancel':
await i.deferUpdate();
await editorMsg.edit({
content: null,
flags: 32768,
components: [{
type: 17,
accent_color: 0xFF0000,
components: [{
type: 10,
content: '**❌ Editor cancelado.**'
}]
}]
});
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: 17,
accent_color: 0x00FF00,
components: [{
type: 10,
content: `**✅ Mob \`${state.key}\` actualizado exitosamente.**`
}]
}]
});
collector.stop('saved');
return;
}
} catch (err) {
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, 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, 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 ?? '' } },
] } as const;
await i.showModal(modal);
try {
const sub = await i.awaitModalSubmit({ time: 300_000 });
state.name = sub.components.getTextInputValue('name').trim();
const cat = sub.components.getTextInputValue('category')?.trim();
state.category = cat || undefined;
await sub.deferUpdate();
await editorMsg.edit({
content: null,
flags: 32768,
components: buildComponents()
}); });
} catch {} } 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 current = JSON.stringify(state[field] ?? {});
const modal = { title, customId: `mb_json_${field}`, components: [ 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) } }, { 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) { if (raw) {
try { try {
state[field] = JSON.parse(raw); state[field] = JSON.parse(raw);
await sub.reply({ content: '✅ Guardado.', flags: MessageFlags.Ephemeral }); await sub.deferUpdate();
} catch { } catch {
await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral }); await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral });
return; return;
} }
} else { } else {
state[field] = {}; state[field] = {};
await sub.reply({ content: ' Limpio.', flags: MessageFlags.Ephemeral }); await sub.deferUpdate();
} }
// Refresh display
const newDisplay = createMobDisplay(state, editing);
await editorMsg.edit({ await editorMsg.edit({
content: null,
flags: 32768, flags: 32768,
components: [ components: buildComponents()
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' },
]
}
]
}); });
} catch {} } catch {}
} }

View File

@@ -2,8 +2,9 @@ import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { hasManageGuildOrStaff } from '../../../core/lib/permissions'; import { hasManageGuildOrStaff } from '../../../core/lib/permissions';
import { prisma } from '../../../core/database/prisma'; 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 { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10';
import { promptKeySelection, resolveItemIcon } from './_helpers';
interface OfferState { interface OfferState {
offerId: string; offerId: string;
@@ -23,23 +24,64 @@ export const command: CommandMessage = {
aliases: ['editar-oferta','offeredit'], aliases: ['editar-oferta','offeredit'],
cooldown: 10, cooldown: 10,
description: 'Edita una ShopOffer por ID con editor interactivo (price/ventanas/stock/limit).', description: 'Edita una ShopOffer por ID con editor interactivo (price/ventanas/stock/limit).',
usage: 'offer-editar <offerId>', usage: 'offer-editar',
run: async (message, args, _client: Amayo) => { run: async (message, _args, _client: Amayo) => {
const channel = message.channel as TextBasedChannel & { send: Function };
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, prisma); 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; } if (!allowed) {
await (channel.send as any)({
const offerId = args[0]?.trim(); content: null,
if (!offerId) { await message.reply('Uso: `!offer-editar <offerId>`'); return; } 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 guildId = message.guild!.id;
const offer = await prisma.shopOffer.findUnique({ where: { id: offerId } }); const offers = await prisma.shopOffer.findMany({
if (!offer || offer.guildId !== guildId) { await message.reply('❌ Oferta no encontrada para este servidor.'); return; } 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 = { const state: OfferState = {
offerId, offerId: offer.id,
itemKey: item?.key, itemKey: offer.item?.key,
enabled: offer.enabled, enabled: offer.enabled,
price: offer.price ?? {}, price: offer.price ?? {},
startAt: offer.startAt ? new Date(offer.startAt).toISOString() : '', startAt: offer.startAt ? new Date(offer.startAt).toISOString() : '',
@@ -49,38 +91,103 @@ export const command: CommandMessage = {
metadata: offer.metadata ?? {}, metadata: offer.metadata ?? {},
}; };
const editorMsg = await (message.channel as any).send({ const buildEditorDisplay = () => {
content: `🛒 Editor de Oferta (editar): ${offerId}`, 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: [ components: [
{ type: 1, components: [
{ type: 2, style: ButtonStyle.Primary, label: 'Base', custom_id: 'of_base' }, { 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: 'Precio (JSON)', custom_id: 'of_price' },
{ type: 2, style: ButtonStyle.Secondary, label: 'Ventana', custom_id: 'of_window' }, { 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: 'Límites', custom_id: 'of_limits' },
{ type: 2, style: ButtonStyle.Secondary, label: 'Meta (JSON)', custom_id: 'of_meta' }, { 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.Success, label: 'Guardar', custom_id: 'of_save' },
{ type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'of_cancel' }, { 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) => { collector.on('collect', async (i: MessageComponentInteraction) => {
try { try {
if (!i.isButton()) return; if (!i.isButton()) return;
switch (i.customId) { switch (i.customId) {
case 'of_cancel': await i.deferUpdate(); await editorMsg.edit({ content: '❌ Editor de Oferta cancelado.', components: [] }); collector.stop('cancel'); return; case 'of_cancel':
case 'of_base': await showBaseModal(i as ButtonInteraction, state); return; await i.deferUpdate();
case 'of_price': await showJsonModal(i as ButtonInteraction, state, 'price', 'Precio'); return; await editorMsg.edit({
case 'of_window': await showWindowModal(i as ButtonInteraction, state); return; content: null,
case 'of_limits': await showLimitsModal(i as ButtonInteraction, state); return; flags: 32768,
case 'of_meta': await showJsonModal(i as ButtonInteraction, state, 'metadata', 'Meta'); return; 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': 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' }] }); 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 { try {
await prisma.shopOffer.update({ await prisma.shopOffer.update({
where: { id: state.offerId }, where: { id: state.offerId },
@@ -96,7 +203,18 @@ export const command: CommandMessage = {
} }
}); });
await i.reply({ content: '✅ Oferta actualizada.', flags: MessageFlags.Ephemeral }); 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'); collector.stop('saved');
} catch (err: any) { } catch (err: any) {
await i.reply({ content: `❌ Error al guardar: ${err?.message ?? err}`, flags: MessageFlags.Ephemeral }); 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 }); 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: [ 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: '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) } }, { 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; ] } as const;
await i.showModal(modal); 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 current = JSON.stringify(state[field] ?? {});
const modal = { title, customId: `of_json_${field}`, components: [ 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) } }, { type: ComponentType.Label, label: 'JSON', component: { type: ComponentType.TextInput, customId: 'json', style: TextInputStyle.Paragraph, required: false, value: current.slice(0,4000) } },
] } as const; ] } as const;
await i.showModal(modal); 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: [ 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: '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 ?? '' } }, { type: ComponentType.Label, label: 'Fin (ISO opcional)', component: { type: ComponentType.TextInput, customId: 'end', style: TextInputStyle.Short, required: false, value: state.endAt ?? '' } },
] } as const; ] } as const;
await i.showModal(modal); 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: [ 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: '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) : '' } }, { 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; ] } as const;
await i.showModal(modal); 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 {}
} }

View File

@@ -1,10 +1,13 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { runMinigame } from '../../../game/minigames/service'; 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 { updateStats } from '../../../game/stats/service';
import { updateQuestProgress } from '../../../game/quests/service'; import { updateQuestProgress } from '../../../game/quests/service';
import { checkAchievements } from '../../../game/achievements/service'; import { checkAchievements } from '../../../game/achievements/service';
import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2';
const FIGHT_ACCENT = 0x992D22;
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'pelear', name: 'pelear',
@@ -42,6 +45,12 @@ export const command: CommandMessage = {
try { try {
const result = await runMinigame(userId, guildId, area.key, level, { toolKey: toolKey ?? undefined }); 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 // Actualizar stats y misiones
await updateStats(userId, guildId, { fightsCompleted: 1 }); await updateStats(userId, guildId, { fightsCompleted: 1 });
await updateQuestProgress(userId, guildId, 'fight_count', 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 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 rewardLines = result.rewards.length
const mobs = result.mobs.length ? result.mobs.join(', ') : '—'; ? result.rewards.map((r) => {
const toolInfo = result.tool?.key ? `🗡️ ${result.tool.key}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta} dur.)`}` : '—'; 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.)`}`
: '—';
let response = globalNotice ? `${globalNotice}\n\n` : ''; const blocks = [textBlock('# ⚔️ Arena')];
response += `⚔️ Arena (nivel ${level})\nRecompensas: ${rewards}\nEnemigos: ${mobs}\nArma: ${toolInfo}`;
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) { if (newAchievements.length > 0) {
response += `\n\n🏆 ¡Logro desbloqueado!`; blocks.push(dividerBlock({ divider: false, spacing: 2 }));
for (const ach of newAchievements) { const achLines = newAchievements.map(ach => `✨ **${ach.name}** — ${ach.description}`).join('\n');
response += `\n✨ **${ach.name}** - ${ach.description}`; blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`));
}
} }
await message.reply(response); const display = buildDisplay(FIGHT_ACCENT, blocks);
await sendDisplayReply(message, display);
} catch (e: any) { } catch (e: any) {
await message.reply(`❌ No se pudo pelear: ${e?.message ?? e}`); await message.reply(`❌ No se pudo pelear: ${e?.message ?? e}`);
} }

View File

@@ -1,10 +1,13 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { runMinigame } from '../../../game/minigames/service'; 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 { updateStats } from '../../../game/stats/service';
import { updateQuestProgress } from '../../../game/quests/service'; import { updateQuestProgress } from '../../../game/quests/service';
import { checkAchievements } from '../../../game/achievements/service'; import { checkAchievements } from '../../../game/achievements/service';
import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2';
const FISHING_ACCENT = 0x1ABC9C;
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'pescar', name: 'pescar',
@@ -42,29 +45,55 @@ export const command: CommandMessage = {
try { try {
const result = await runMinigame(userId, guildId, area.key, level, { toolKey: toolKey ?? undefined }); 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 // Actualizar stats y misiones
await updateStats(userId, guildId, { fishingCompleted: 1 }); await updateStats(userId, guildId, { fishingCompleted: 1 });
await updateQuestProgress(userId, guildId, 'fish_count', 1); await updateQuestProgress(userId, guildId, 'fish_count', 1);
const newAchievements = await checkAchievements(userId, guildId, 'fish_count'); 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 rewardLines = result.rewards.length
const mobs = result.mobs.length ? result.mobs.join(', ') : '—'; ? result.rewards.map((r) => {
const toolInfo = result.tool?.key ? `🎣 ${result.tool.key}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta} dur.)`}` : '—'; 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.)`}`
: '—';
let response = globalNotice ? `${globalNotice}\n\n` : ''; const blocks = [textBlock('# 🎣 Pesca')];
response += `🎣 Pesca (nivel ${level})
Recompensas: ${rewards} if (globalNotice) {
Mobs: ${mobs} blocks.push(dividerBlock({ divider: false, spacing: 1 }));
Herramienta: ${toolInfo}`; 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) { if (newAchievements.length > 0) {
response += `\n\n🏆 ¡Logro desbloqueado!`; blocks.push(dividerBlock({ divider: false, spacing: 2 }));
for (const ach of newAchievements) { const achLines = newAchievements.map(ach => `✨ **${ach.name}** — ${ach.description}`).join('\n');
response += `\n✨ **${ach.name}** - ${ach.description}`; blocks.push(textBlock(`🏆 ¡Logro desbloqueado!\n${achLines}`));
}
} }
await message.reply(response); const display = buildDisplay(FISHING_ACCENT, blocks);
await sendDisplayReply(message, display);
} catch (e: any) { } catch (e: any) {
await message.reply(`❌ No se pudo pescar: ${e?.message ?? e}`); await message.reply(`❌ No se pudo pescar: ${e?.message ?? e}`);
} }

View File

@@ -1,7 +1,10 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { runMinigame } from '../../../game/minigames/service'; 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 = { export const command: CommandMessage = {
name: 'plantar', name: 'plantar',
@@ -26,13 +29,40 @@ export const command: CommandMessage = {
try { try {
const result = await runMinigame(userId, guildId, areaKey, level, { toolKey: toolKey ?? undefined }); 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 rewardKeys = result.rewards
const toolInfo = result.tool?.key ? `🪓 ${result.tool.key}${result.tool.broken ? ' (rota)' : ` (-${result.tool.durabilityDelta} dur.)`}` : '—'; .filter((r): r is { type: 'item'; itemKey: string; qty?: number } => r.type === 'item' && Boolean(r.itemKey))
await message.reply(`🌱 Campo (nivel ${level}) .map((r) => r.itemKey!);
Recompensas: ${rewards} if (result.tool?.key) rewardKeys.push(result.tool.key);
Eventos: ${mobs} const rewardItems = await fetchItemBasics(guildId, rewardKeys);
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('# 🌱 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) { } catch (e: any) {
await message.reply(`❌ No se pudo plantar: ${e?.message ?? e}`); await message.reply(`❌ No se pudo plantar: ${e?.message ?? e}`);
} }

View File

@@ -5,6 +5,7 @@ import { getOrCreateWallet } from '../../../game/economy/service';
import { getEquipment, getEffectiveStats } from '../../../game/combat/equipmentService'; import { getEquipment, getEffectiveStats } from '../../../game/combat/equipmentService';
import { getPlayerStatsFormatted } from '../../../game/stats/service'; import { getPlayerStatsFormatted } from '../../../game/stats/service';
import type { TextBasedChannel } from 'discord.js'; import type { TextBasedChannel } from 'discord.js';
import { formatItemLabel } from './_helpers';
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'player', name: 'player',
@@ -49,6 +50,16 @@ export const command: CommandMessage = {
take: 3, 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 // Crear DisplayComponent
const display = { const display = {
type: 17, type: 17,
@@ -71,9 +82,9 @@ export const command: CommandMessage = {
{ {
type: 10, type: 10,
content: `**⚔️ EQUIPO**\n` + content: `**⚔️ EQUIPO**\n` +
(weapon ? `🗡️ Arma: **${weapon.name || weapon.key}**\n` : '🗡️ Arma: *Ninguna*\n') + `${weaponLine}\n` +
(armor ? `🛡️ Armadura: **${armor.name || armor.key}**\n` : '🛡️ Armadura: *Ninguna*\n') + `${armorLine}\n` +
(cape ? `🧥 Capa: **${cape.name || cape.key}**` : '🧥 Capa: *Ninguna*') `${capeLine}`
}, },
{ type: 14, divider: true }, { type: 14, divider: true },
{ {

View File

@@ -2,6 +2,7 @@ import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client'; import type Amayo from '../../../core/client';
import { getStreakInfo, updateStreak } from '../../../game/streaks/service'; import { getStreakInfo, updateStreak } from '../../../game/streaks/service';
import type { TextBasedChannel } from 'discord.js'; import type { TextBasedChannel } from 'discord.js';
import { fetchItemBasics, formatItemLabel } from './_helpers';
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'racha', name: 'racha',
@@ -62,9 +63,12 @@ export const command: CommandMessage = {
if (rewards) { if (rewards) {
let rewardsText = '**🎁 RECOMPENSA DEL DÍA**\n'; let rewardsText = '**🎁 RECOMPENSA DEL DÍA**\n';
if (rewards.coins) rewardsText += `💰 **${rewards.coins.toLocaleString()}** monedas\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 => { 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`;
}); });
} }

View File

@@ -13,6 +13,7 @@ import { prisma } from '../../../core/database/prisma';
import { getOrCreateWallet, buyFromOffer } from '../../../game/economy/service'; import { getOrCreateWallet, buyFromOffer } from '../../../game/economy/service';
import type { DisplayComponentContainer } from '../../../core/types/displayComponents'; import type { DisplayComponentContainer } from '../../../core/types/displayComponents';
import type { ItemProps } from '../../../game/economy/types'; import type { ItemProps } from '../../../game/economy/types';
import { formatItemLabel, resolveItemIcon } from './_helpers';
const ITEMS_PER_PAGE = 5; const ITEMS_PER_PAGE = 5;
@@ -198,7 +199,7 @@ async function buildShopPanel(
if (selectedOffer) { if (selectedOffer) {
const item = selectedOffer.item; const item = selectedOffer.item;
const props = parseItemProps(item.props); 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(selectedOffer.price); const price = formatPrice(selectedOffer.price);
// Stock info // Stock info
@@ -225,7 +226,7 @@ async function buildShopPanel(
container.components.push({ container.components.push({
type: 10, 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({ container.components.push({
@@ -244,7 +245,7 @@ async function buildShopPanel(
for (const offer of pageOffers) { for (const offer of pageOffers) {
const item = offer.item; const item = offer.item;
const props = parseItemProps(item.props); 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 price = formatPrice(offer.price);
const isSelected = selectedOfferId === offer.id; const isSelected = selectedOfferId === offer.id;
@@ -255,7 +256,7 @@ async function buildShopPanel(
type: 9, type: 9,
components: [{ components: [{
type: 10, type: 10,
content: `${icon} **${item.name || item.key}**${selectedMark}\n💰 ${price}${stockText}` content: `${label}${selectedMark}\n💰 ${price}${stockText}`
}], }],
accessory: { accessory: {
type: 2, type: 2,
@@ -370,8 +371,9 @@ async function handleButtonInteraction(
const result = await buyFromOffer(userId, guildId, selectedOfferId, qty); const result = await buyFromOffer(userId, guildId, selectedOfferId, qty);
const wallet = await getOrCreateWallet(userId, guildId); const wallet = await getOrCreateWallet(userId, guildId);
const purchaseLabel = formatItemLabel(result.item, { fallbackIcon: resolveItemIcon(result.item.icon) });
await interaction.followUp({ 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 flags: MessageFlags.Ephemeral
}); });