feat(economy): add interactive achievement and quest creation commands with DisplayComponents
This commit is contained in:
298
DISPLAYCOMPONENTS_IMPLEMENTATION.md
Normal file
298
DISPLAYCOMPONENTS_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
# 🎨 Implementación de DisplayComponents y Comandos Admin
|
||||||
|
|
||||||
|
## ✅ Nuevos Comandos Administrativos Creados
|
||||||
|
|
||||||
|
### 1. **!logro-crear** (`src/commands/messages/admin/logroCrear.ts`)
|
||||||
|
Editor interactivo completo para crear logros usando DisplayComponents.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- ✅ Preview visual con DisplayComponents
|
||||||
|
- ✅ Modales interactivos para editar cada sección
|
||||||
|
- ✅ Sections: Base, Requisitos, Recompensas
|
||||||
|
- ✅ Validación de JSON para requisitos y recompensas
|
||||||
|
- ✅ Botones de navegación intuitivos
|
||||||
|
- ✅ Auto-guardado con confirmación
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```
|
||||||
|
!logro-crear <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Display Components utilizados:**
|
||||||
|
- Container (type 17) - Contenedor principal con accent_color
|
||||||
|
- Section (type 9) - Secciones organizadas
|
||||||
|
- TextDisplay (type 10) - Contenido de texto formateado
|
||||||
|
- Separator (type 14) - Divisores visuales
|
||||||
|
- Modales con Label + TextInput para entrada de datos
|
||||||
|
- TextDisplay en modales para instrucciones
|
||||||
|
|
||||||
|
### 2. **!mision-crear** (`src/commands/messages/admin/misionCrear.ts`)
|
||||||
|
Editor interactivo completo para crear misiones usando DisplayComponents.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- ✅ Preview visual con DisplayComponents
|
||||||
|
- ✅ Modales para Base, Requisitos y Recompensas
|
||||||
|
- ✅ Soporte para tipos: daily, weekly, permanent, event
|
||||||
|
- ✅ Validación de JSON
|
||||||
|
- ✅ Emojis contextuales según tipo de misión
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```
|
||||||
|
!mision-crear <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 DisplayComponents - Guía de Uso
|
||||||
|
|
||||||
|
### Tipos de Componentes Implementados
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Container - Contenedor principal (type 17)
|
||||||
|
{
|
||||||
|
type: 17,
|
||||||
|
accent_color: 0xFFD700, // Color hex
|
||||||
|
components: [ /* otros componentes */ ]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section - Sección con contenido (type 9)
|
||||||
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [ /* TextDisplay, etc */ ]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextDisplay - Texto formateado (type 10)
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: "**Bold** *Italic* `Code` ```json\n{}\n```"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator - Divisor visual (type 14)
|
||||||
|
{
|
||||||
|
type: 14,
|
||||||
|
divider: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail - Imagen/thumbnail (type 11)
|
||||||
|
{
|
||||||
|
type: 11,
|
||||||
|
media: { url: "https://..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modales con DisplayComponents
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const modal = {
|
||||||
|
title: 'Título del Modal',
|
||||||
|
customId: 'modal_id',
|
||||||
|
components: [
|
||||||
|
// TextDisplay para instrucciones
|
||||||
|
{
|
||||||
|
type: ComponentType.TextDisplay,
|
||||||
|
content: 'Instrucciones aquí'
|
||||||
|
},
|
||||||
|
// Label con TextInput
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Campo a llenar',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'field_id',
|
||||||
|
style: TextInputStyle.Short, // o Paragraph
|
||||||
|
required: true,
|
||||||
|
value: 'Valor actual',
|
||||||
|
placeholder: 'Placeholder...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responder a Modal Submits
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const submit = await interaction.awaitModalSubmit({
|
||||||
|
time: 5 * 60_000
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!submit) return;
|
||||||
|
|
||||||
|
const value = submit.components.getTextInputValue('field_id');
|
||||||
|
|
||||||
|
// Actualizar display
|
||||||
|
await submit.deferUpdate();
|
||||||
|
await message.edit({ display: newDisplay });
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Estructura de Display
|
||||||
|
|
||||||
|
Los comandos admin siguen esta estructura:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Container (accent_color) │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ Section: Título │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ Separator (divider) │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ Section: Campos Base │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ Separator │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ Section: Requisitos (JSON) │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ Separator │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ Section: Recompensas (JSON) │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Integración con Comandos Existentes
|
||||||
|
|
||||||
|
### Próximos comandos a actualizar con DisplayComponents:
|
||||||
|
|
||||||
|
1. **!inventario** - Lista de items visual con thumbnails
|
||||||
|
2. **!tienda** - Catálogo visual de items
|
||||||
|
3. **!player** - Stats del jugador en formato visual
|
||||||
|
4. **!item-crear** - Mejorar con DisplayComponents
|
||||||
|
5. **!area-crear** - Mejorar con DisplayComponents
|
||||||
|
6. **!mob-crear** - Mejorar con DisplayComponents
|
||||||
|
|
||||||
|
### Patrón recomendado para actualizar comandos:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Crear función de display
|
||||||
|
function createDisplay(data: any) {
|
||||||
|
return {
|
||||||
|
display: {
|
||||||
|
type: 17,
|
||||||
|
accent_color: 0xCOLOR,
|
||||||
|
components: [
|
||||||
|
// Secciones aquí
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Enviar con botones
|
||||||
|
const msg = await channel.send({
|
||||||
|
...createDisplay(data),
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.ActionRow,
|
||||||
|
components: [ /* botones */ ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Collector para interacciones
|
||||||
|
const collector = msg.createMessageComponentCollector({ ... });
|
||||||
|
|
||||||
|
// 4. Actualizar display al cambiar datos
|
||||||
|
await msg.edit(createDisplay(updatedData));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Ventajas de DisplayComponents
|
||||||
|
|
||||||
|
### vs. Embeds tradicionales:
|
||||||
|
- ✅ Más moderno y visual
|
||||||
|
- ✅ Mejor separación de secciones
|
||||||
|
- ✅ Dividers nativos
|
||||||
|
- ✅ Accent color personalizable
|
||||||
|
- ✅ Mejor para contenido largo
|
||||||
|
- ✅ Soporte nativo en discord.js dev
|
||||||
|
|
||||||
|
### vs. Texto plano:
|
||||||
|
- ✅ Muchísimo más visual
|
||||||
|
- ✅ Organización clara
|
||||||
|
- ✅ Professional look
|
||||||
|
- ✅ Mejor UX para el usuario
|
||||||
|
- ✅ Más información en menos espacio
|
||||||
|
|
||||||
|
## 📝 Notas de Implementación
|
||||||
|
|
||||||
|
### Tipos de Componente (ComponentType):
|
||||||
|
- `17` - Container
|
||||||
|
- `9` - Section
|
||||||
|
- `10` - TextDisplay
|
||||||
|
- `14` - Separator
|
||||||
|
- `11` - Thumbnail
|
||||||
|
- `2` - Button (ActionRow)
|
||||||
|
- `3` - Select Menu
|
||||||
|
- `4` - TextInput (en modales)
|
||||||
|
- `40` - Label (wrapper para inputs en modales)
|
||||||
|
|
||||||
|
### Best Practices:
|
||||||
|
|
||||||
|
1. **Usar accent_color** para dar contexto visual
|
||||||
|
- Logros: 0xFFD700 (dorado)
|
||||||
|
- Misiones: 0x5865F2 (azul Discord)
|
||||||
|
- Errores: 0xFF0000 (rojo)
|
||||||
|
- Éxito: 0x00FF00 (verde)
|
||||||
|
|
||||||
|
2. **Separators con divider: true** entre secciones importantes
|
||||||
|
|
||||||
|
3. **TextDisplay soporta Markdown**:
|
||||||
|
- **Bold**, *Italic*, `Code`
|
||||||
|
- ```json code blocks```
|
||||||
|
- Listas, etc.
|
||||||
|
|
||||||
|
4. **Modales siempre usan `as const`** para type safety
|
||||||
|
|
||||||
|
5. **awaitModalSubmit** debe tener timeout y catch
|
||||||
|
|
||||||
|
6. **Siempre hacer deferUpdate()** antes de editar mensaje tras modal
|
||||||
|
|
||||||
|
## 🚀 Testing
|
||||||
|
|
||||||
|
### Comandos a probar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Crear logro
|
||||||
|
!logro-crear test_achievement
|
||||||
|
|
||||||
|
# Crear misión
|
||||||
|
!mision-crear test_quest
|
||||||
|
|
||||||
|
# Verificar que los displays se ven correctamente
|
||||||
|
# Verificar que los modales funcionan
|
||||||
|
# Verificar que el guardado funciona
|
||||||
|
# Verificar que los datos se persisten en DB
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verificar en Discord:
|
||||||
|
1. Los DisplayComponents se renderizan correctamente
|
||||||
|
2. Los separators dividen las secciones
|
||||||
|
3. El accent_color se muestra
|
||||||
|
4. Los botones son clickeables
|
||||||
|
5. Los modales se abren
|
||||||
|
6. Los TextDisplay en modales son visibles
|
||||||
|
7. Los datos se guardan correctamente
|
||||||
|
|
||||||
|
## 📚 Recursos
|
||||||
|
|
||||||
|
- **Ejemplo oficial**: `example.ts.txt` en la raíz del proyecto
|
||||||
|
- **Tipos**: `src/core/types/displayComponents.ts`
|
||||||
|
- **Discord.js types**: `node_modules/discord.js/typings/index.d.ts`
|
||||||
|
- **API Types**: `node_modules/discord-api-types/`
|
||||||
|
|
||||||
|
## ⚠️ Limitaciones Conocidas
|
||||||
|
|
||||||
|
1. DisplayComponents son beta en discord.js
|
||||||
|
2. No todas las features están documentadas
|
||||||
|
3. Algunos componentes pueden no funcionar en mobile
|
||||||
|
4. TextDisplay tiene límite de caracteres (~2000)
|
||||||
|
5. Containers tienen límite de componentes (~25)
|
||||||
|
|
||||||
|
## 🎯 Próximos Pasos
|
||||||
|
|
||||||
|
1. ✅ Comandos admin para logros y misiones - COMPLETADO
|
||||||
|
2. ⬜ Actualizar !inventario con DisplayComponents
|
||||||
|
3. ⬜ Actualizar !tienda con DisplayComponents
|
||||||
|
4. ⬜ Actualizar !player con DisplayComponents
|
||||||
|
5. ⬜ Mejorar !item-crear, !area-crear, !mob-crear
|
||||||
|
6. ⬜ Crear comando !ranking con DisplayComponents
|
||||||
|
7. ⬜ Crear comando !logros con DisplayComponents mejorado
|
||||||
|
8. ⬜ Crear comando !misiones con DisplayComponents mejorado
|
||||||
356
src/commands/messages/admin/logroCrear.ts
Normal file
356
src/commands/messages/admin/logroCrear.ts
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import type { CommandMessage } from '../../../core/types/commands';
|
||||||
|
import type Amayo from '../../../core/client';
|
||||||
|
import { hasManageGuildOrStaff } from '../../../core/lib/permissions';
|
||||||
|
import { prisma } from '../../../core/database/prisma';
|
||||||
|
import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10';
|
||||||
|
import type { ButtonInteraction, MessageComponentInteraction, TextBasedChannel } from 'discord.js';
|
||||||
|
|
||||||
|
interface AchievementState {
|
||||||
|
key: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
icon?: string;
|
||||||
|
requirements?: any;
|
||||||
|
rewards?: any;
|
||||||
|
points?: number;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const command: CommandMessage = {
|
||||||
|
name: 'logro-crear',
|
||||||
|
type: 'message',
|
||||||
|
aliases: ['crear-logro', 'achievement-create'],
|
||||||
|
cooldown: 10,
|
||||||
|
description: 'Crea un logro para el servidor con editor interactivo',
|
||||||
|
usage: 'logro-crear <key-única>',
|
||||||
|
run: async (message, args, client: Amayo) => {
|
||||||
|
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, prisma);
|
||||||
|
if (!allowed) {
|
||||||
|
await message.reply('❌ No tienes permisos de ManageGuild ni rol de staff.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = args[0]?.trim();
|
||||||
|
if (!key) {
|
||||||
|
await message.reply('Uso: `!logro-crear <key-única>`\nEjemplo: `!logro-crear master_fisher`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildId = message.guild!.id;
|
||||||
|
const exists = await prisma.achievement.findFirst({ where: { key, guildId } });
|
||||||
|
if (exists) {
|
||||||
|
await message.reply('❌ Ya existe un logro con esa key en este servidor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: AchievementState = {
|
||||||
|
key,
|
||||||
|
category: 'economy',
|
||||||
|
points: 10,
|
||||||
|
hidden: false,
|
||||||
|
requirements: { type: 'mine_count', value: 1 },
|
||||||
|
rewards: { coins: 100 }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crear mensaje con DisplayComponents
|
||||||
|
const displayMessage = createDisplay(state);
|
||||||
|
|
||||||
|
const channel = message.channel as TextBasedChannel & { send: Function };
|
||||||
|
const editorMsg = await channel.send({
|
||||||
|
...displayMessage,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.ActionRow,
|
||||||
|
components: [
|
||||||
|
{ type: ComponentType.Button, style: ButtonStyle.Primary, label: 'Base', custom_id: 'ach_base' },
|
||||||
|
{ type: ComponentType.Button, style: ButtonStyle.Secondary, label: 'Requisitos', custom_id: 'ach_req' },
|
||||||
|
{ type: ComponentType.Button, style: ButtonStyle.Secondary, label: 'Recompensas', custom_id: 'ach_reward' },
|
||||||
|
{ type: ComponentType.Button, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'ach_save' },
|
||||||
|
{ type: ComponentType.Button, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'ach_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;
|
||||||
|
|
||||||
|
switch (i.customId) {
|
||||||
|
case 'ach_cancel':
|
||||||
|
await i.deferUpdate();
|
||||||
|
await editorMsg.edit({ content: '❌ Creación de logro cancelada.', components: [], display: undefined });
|
||||||
|
collector.stop('cancel');
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'ach_base':
|
||||||
|
await showBaseModal(i as ButtonInteraction, state, editorMsg);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'ach_req':
|
||||||
|
await showRequirementsModal(i as ButtonInteraction, state, editorMsg);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'ach_reward':
|
||||||
|
await showRewardsModal(i as ButtonInteraction, state, editorMsg);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'ach_save':
|
||||||
|
if (!state.name || !state.description) {
|
||||||
|
await i.reply({ content: '❌ Completa al menos el nombre y descripción.', flags: 64 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.achievement.create({
|
||||||
|
data: {
|
||||||
|
guildId,
|
||||||
|
key: state.key,
|
||||||
|
name: state.name!,
|
||||||
|
description: state.description!,
|
||||||
|
category: state.category || 'economy',
|
||||||
|
icon: state.icon,
|
||||||
|
requirements: state.requirements as any || {},
|
||||||
|
rewards: state.rewards as any || {},
|
||||||
|
points: state.points || 10,
|
||||||
|
hidden: state.hidden || false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await i.reply({ content: '✅ Logro creado exitosamente.', flags: 64 });
|
||||||
|
await editorMsg.edit({
|
||||||
|
content: `✅ Logro \`${state.key}\` creado.`,
|
||||||
|
components: [],
|
||||||
|
display: undefined
|
||||||
|
});
|
||||||
|
collector.stop('saved');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error en editor de logros:', e);
|
||||||
|
if (!i.deferred && !i.replied) {
|
||||||
|
await i.reply({ content: '❌ Error procesando la acción.', flags: 64 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', async (_c, r) => {
|
||||||
|
if (r === 'time') {
|
||||||
|
try {
|
||||||
|
await editorMsg.edit({ content: '⏰ Editor expirado.', components: [], display: undefined });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDisplay(state: AchievementState) {
|
||||||
|
return {
|
||||||
|
display: {
|
||||||
|
type: 17, // Container
|
||||||
|
accent_color: 0xFFD700,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 9, // Section
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `**🏆 Creando Logro: \`${state.key}\`**`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 14, divider: true }, // Separator
|
||||||
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: `**Nombre:** ${state.name || '*Sin definir*'}\n**Descripción:** ${state.description || '*Sin definir*'}\n**Categoría:** ${state.category || 'economy'}\n**Icono:** ${state.icon || '🏆'}\n**Puntos:** ${state.points || 10}\n**Oculto:** ${state.hidden ? 'Sí' : 'No'}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 14, divider: true },
|
||||||
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: `**Requisitos:**\n\`\`\`json\n${JSON.stringify(state.requirements, null, 2)}\n\`\`\``
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 14, divider: true },
|
||||||
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: `**Recompensas:**\n\`\`\`json\n${JSON.stringify(state.rewards, null, 2)}\n\`\`\``
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showBaseModal(i: ButtonInteraction, state: AchievementState, editorMsg: any) {
|
||||||
|
const modal = {
|
||||||
|
title: 'Información Base del Logro',
|
||||||
|
customId: 'ach_base_modal',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Nombre del logro',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'name',
|
||||||
|
style: TextInputStyle.Short,
|
||||||
|
required: true,
|
||||||
|
value: state.name || '',
|
||||||
|
placeholder: 'Ej: Maestro Pescador'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Descripción',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'description',
|
||||||
|
style: TextInputStyle.Paragraph,
|
||||||
|
required: true,
|
||||||
|
value: state.description || '',
|
||||||
|
placeholder: 'Ej: Pesca 100 veces'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Categoría (mining/fishing/combat/economy/crafting)',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'category',
|
||||||
|
style: TextInputStyle.Short,
|
||||||
|
required: false,
|
||||||
|
value: state.category || 'economy'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Icono (emoji)',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'icon',
|
||||||
|
style: TextInputStyle.Short,
|
||||||
|
required: false,
|
||||||
|
value: state.icon || '🏆'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Puntos (número)',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'points',
|
||||||
|
style: TextInputStyle.Short,
|
||||||
|
required: false,
|
||||||
|
value: String(state.points || 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await i.showModal(modal);
|
||||||
|
|
||||||
|
const submit = await i.awaitModalSubmit({ time: 5 * 60_000 }).catch(() => null);
|
||||||
|
if (!submit) return;
|
||||||
|
|
||||||
|
state.name = submit.components.getTextInputValue('name');
|
||||||
|
state.description = submit.components.getTextInputValue('description');
|
||||||
|
state.category = submit.components.getTextInputValue('category') || 'economy';
|
||||||
|
state.icon = submit.components.getTextInputValue('icon') || '🏆';
|
||||||
|
state.points = parseInt(submit.components.getTextInputValue('points')) || 10;
|
||||||
|
|
||||||
|
await submit.deferUpdate();
|
||||||
|
await editorMsg.edit(createDisplay(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showRequirementsModal(i: ButtonInteraction, state: AchievementState, editorMsg: any) {
|
||||||
|
const modal = {
|
||||||
|
title: 'Requisitos del Logro',
|
||||||
|
customId: 'ach_req_modal',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.TextDisplay,
|
||||||
|
content: 'Formato JSON con "type" y "value"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Requisitos (JSON)',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'requirements',
|
||||||
|
style: TextInputStyle.Paragraph,
|
||||||
|
required: true,
|
||||||
|
value: JSON.stringify(state.requirements, null, 2),
|
||||||
|
placeholder: '{"type": "mine_count", "value": 100}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await i.showModal(modal);
|
||||||
|
|
||||||
|
const submit = await i.awaitModalSubmit({ time: 5 * 60_000 }).catch(() => null);
|
||||||
|
if (!submit) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.requirements = JSON.parse(submit.components.getTextInputValue('requirements'));
|
||||||
|
await submit.deferUpdate();
|
||||||
|
await editorMsg.edit(createDisplay(state));
|
||||||
|
} catch (e) {
|
||||||
|
await submit.reply({ content: '❌ JSON inválido en requisitos.', flags: 64 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showRewardsModal(i: ButtonInteraction, state: AchievementState, editorMsg: any) {
|
||||||
|
const modal = {
|
||||||
|
title: 'Recompensas del Logro',
|
||||||
|
customId: 'ach_reward_modal',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.TextDisplay,
|
||||||
|
content: 'Formato JSON con coins, items, etc.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Recompensas (JSON)',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'rewards',
|
||||||
|
style: TextInputStyle.Paragraph,
|
||||||
|
required: true,
|
||||||
|
value: JSON.stringify(state.rewards, null, 2),
|
||||||
|
placeholder: '{"coins": 1000, "items": [{"key": "item.key", "quantity": 1}]}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await i.showModal(modal);
|
||||||
|
|
||||||
|
const submit = await i.awaitModalSubmit({ time: 5 * 60_000 }).catch(() => null);
|
||||||
|
if (!submit) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.rewards = JSON.parse(submit.components.getTextInputValue('rewards'));
|
||||||
|
await submit.deferUpdate();
|
||||||
|
await editorMsg.edit(createDisplay(state));
|
||||||
|
} catch (e) {
|
||||||
|
await submit.reply({ content: '❌ JSON inválido en recompensas.', flags: 64 });
|
||||||
|
}
|
||||||
|
}
|
||||||
363
src/commands/messages/admin/misionCrear.ts
Normal file
363
src/commands/messages/admin/misionCrear.ts
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import type { CommandMessage } from '../../../core/types/commands';
|
||||||
|
import type Amayo from '../../../core/client';
|
||||||
|
import { hasManageGuildOrStaff } from '../../../core/lib/permissions';
|
||||||
|
import { prisma } from '../../../core/database/prisma';
|
||||||
|
import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10';
|
||||||
|
import type { ButtonInteraction, MessageComponentInteraction, TextBasedChannel } from 'discord.js';
|
||||||
|
|
||||||
|
interface QuestState {
|
||||||
|
key: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
type?: string;
|
||||||
|
icon?: string;
|
||||||
|
requirements?: any;
|
||||||
|
rewards?: any;
|
||||||
|
repeatable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const command: CommandMessage = {
|
||||||
|
name: 'mision-crear',
|
||||||
|
type: 'message',
|
||||||
|
aliases: ['crear-mision', 'quest-create'],
|
||||||
|
cooldown: 10,
|
||||||
|
description: 'Crea una misión para el servidor con editor interactivo',
|
||||||
|
usage: 'mision-crear <key-única>',
|
||||||
|
run: async (message, args, client: Amayo) => {
|
||||||
|
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, prisma);
|
||||||
|
if (!allowed) {
|
||||||
|
await message.reply('❌ No tienes permisos de ManageGuild ni rol de staff.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = args[0]?.trim();
|
||||||
|
if (!key) {
|
||||||
|
await message.reply('Uso: `!mision-crear <key-única>`\nEjemplo: `!mision-crear daily_mine_10`');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildId = message.guild!.id;
|
||||||
|
const exists = await prisma.quest.findFirst({ where: { key, guildId } });
|
||||||
|
if (exists) {
|
||||||
|
await message.reply('❌ Ya existe una misión con esa key en este servidor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: QuestState = {
|
||||||
|
key,
|
||||||
|
category: 'mining',
|
||||||
|
type: 'daily',
|
||||||
|
repeatable: false,
|
||||||
|
requirements: { type: 'mine_count', count: 10 },
|
||||||
|
rewards: { coins: 500 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayMessage = createDisplay(state);
|
||||||
|
|
||||||
|
const channel = message.channel as TextBasedChannel & { send: Function };
|
||||||
|
const editorMsg = await channel.send({
|
||||||
|
...displayMessage,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.ActionRow,
|
||||||
|
components: [
|
||||||
|
{ type: ComponentType.Button, style: ButtonStyle.Primary, label: 'Base', custom_id: 'quest_base' },
|
||||||
|
{ type: ComponentType.Button, style: ButtonStyle.Secondary, label: 'Requisitos', custom_id: 'quest_req' },
|
||||||
|
{ type: ComponentType.Button, style: ButtonStyle.Secondary, label: 'Recompensas', custom_id: 'quest_reward' },
|
||||||
|
{ type: ComponentType.Button, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'quest_save' },
|
||||||
|
{ type: ComponentType.Button, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'quest_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;
|
||||||
|
|
||||||
|
switch (i.customId) {
|
||||||
|
case 'quest_cancel':
|
||||||
|
await i.deferUpdate();
|
||||||
|
await editorMsg.edit({ content: '❌ Creación de misión cancelada.', components: [], display: undefined });
|
||||||
|
collector.stop('cancel');
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'quest_base':
|
||||||
|
await showBaseModal(i as ButtonInteraction, state, editorMsg);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'quest_req':
|
||||||
|
await showRequirementsModal(i as ButtonInteraction, state, editorMsg);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'quest_reward':
|
||||||
|
await showRewardsModal(i as ButtonInteraction, state, editorMsg);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'quest_save':
|
||||||
|
if (!state.name || !state.description) {
|
||||||
|
await i.reply({ content: '❌ Completa al menos el nombre y descripción.', flags: 64 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.quest.create({
|
||||||
|
data: {
|
||||||
|
guildId,
|
||||||
|
key: state.key,
|
||||||
|
name: state.name!,
|
||||||
|
description: state.description!,
|
||||||
|
category: state.category || 'mining',
|
||||||
|
type: state.type || 'daily',
|
||||||
|
icon: state.icon,
|
||||||
|
requirements: state.requirements as any || {},
|
||||||
|
rewards: state.rewards as any || {},
|
||||||
|
repeatable: state.repeatable || false,
|
||||||
|
active: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await i.reply({ content: '✅ Misión creada exitosamente.', flags: 64 });
|
||||||
|
await editorMsg.edit({
|
||||||
|
content: `✅ Misión \`${state.key}\` creada.`,
|
||||||
|
components: [],
|
||||||
|
display: undefined
|
||||||
|
});
|
||||||
|
collector.stop('saved');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error en editor de misiones:', e);
|
||||||
|
if (!i.deferred && !i.replied) {
|
||||||
|
await i.reply({ content: '❌ Error procesando la acción.', flags: 64 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', async (_c, r) => {
|
||||||
|
if (r === 'time') {
|
||||||
|
try {
|
||||||
|
await editorMsg.edit({ content: '⏰ Editor expirado.', components: [], display: undefined });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDisplay(state: QuestState) {
|
||||||
|
const typeEmojis: Record<string, string> = {
|
||||||
|
daily: '📅',
|
||||||
|
weekly: '📆',
|
||||||
|
permanent: '♾️',
|
||||||
|
event: '🎉'
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
display: {
|
||||||
|
type: 17, // Container
|
||||||
|
accent_color: 0x5865F2,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 9, // Section
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10, // Text Display
|
||||||
|
content: `**📜 Creando Misión: \`${state.key}\`**`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 14, divider: true }, // Separator
|
||||||
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: `**Nombre:** ${state.name || '*Sin definir*'}\n**Descripción:** ${state.description || '*Sin definir*'}\n**Categoría:** ${state.category || 'mining'}\n**Tipo:** ${typeEmojis[state.type || 'daily']} ${state.type || 'daily'}\n**Icono:** ${state.icon || '📋'}\n**Repetible:** ${state.repeatable ? 'Sí' : 'No'}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 14, divider: true },
|
||||||
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: `**Requisitos:**\n\`\`\`json\n${JSON.stringify(state.requirements, null, 2)}\n\`\`\``
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 14, divider: true },
|
||||||
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: `**Recompensas:**\n\`\`\`json\n${JSON.stringify(state.rewards, null, 2)}\n\`\`\``
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showBaseModal(i: ButtonInteraction, state: QuestState, editorMsg: any) {
|
||||||
|
const modal = {
|
||||||
|
title: 'Información Base de la Misión',
|
||||||
|
customId: 'quest_base_modal',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Nombre de la misión',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'name',
|
||||||
|
style: TextInputStyle.Short,
|
||||||
|
required: true,
|
||||||
|
value: state.name || '',
|
||||||
|
placeholder: 'Ej: Minero Diario'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Descripción',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'description',
|
||||||
|
style: TextInputStyle.Paragraph,
|
||||||
|
required: true,
|
||||||
|
value: state.description || '',
|
||||||
|
placeholder: 'Ej: Mina 10 veces hoy'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Categoría (mining/fishing/combat/economy/crafting)',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'category',
|
||||||
|
style: TextInputStyle.Short,
|
||||||
|
required: false,
|
||||||
|
value: state.category || 'mining'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Tipo (daily/weekly/permanent/event)',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'type',
|
||||||
|
style: TextInputStyle.Short,
|
||||||
|
required: false,
|
||||||
|
value: state.type || 'daily'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Icono (emoji)',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'icon',
|
||||||
|
style: TextInputStyle.Short,
|
||||||
|
required: false,
|
||||||
|
value: state.icon || '📋'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await i.showModal(modal);
|
||||||
|
|
||||||
|
const submit = await i.awaitModalSubmit({ time: 5 * 60_000 }).catch(() => null);
|
||||||
|
if (!submit) return;
|
||||||
|
|
||||||
|
state.name = submit.components.getTextInputValue('name');
|
||||||
|
state.description = submit.components.getTextInputValue('description');
|
||||||
|
state.category = submit.components.getTextInputValue('category') || 'mining';
|
||||||
|
state.type = submit.components.getTextInputValue('type') || 'daily';
|
||||||
|
state.icon = submit.components.getTextInputValue('icon') || '📋';
|
||||||
|
|
||||||
|
await submit.deferUpdate();
|
||||||
|
await editorMsg.edit(createDisplay(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showRequirementsModal(i: ButtonInteraction, state: QuestState, editorMsg: any) {
|
||||||
|
const modal = {
|
||||||
|
title: 'Requisitos de la Misión',
|
||||||
|
customId: 'quest_req_modal',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.TextDisplay,
|
||||||
|
content: 'Formato JSON con "type" y "count"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Requisitos (JSON)',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'requirements',
|
||||||
|
style: TextInputStyle.Paragraph,
|
||||||
|
required: true,
|
||||||
|
value: JSON.stringify(state.requirements, null, 2),
|
||||||
|
placeholder: '{"type": "mine_count", "count": 10}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await i.showModal(modal);
|
||||||
|
|
||||||
|
const submit = await i.awaitModalSubmit({ time: 5 * 60_000 }).catch(() => null);
|
||||||
|
if (!submit) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.requirements = JSON.parse(submit.components.getTextInputValue('requirements'));
|
||||||
|
await submit.deferUpdate();
|
||||||
|
await editorMsg.edit(createDisplay(state));
|
||||||
|
} catch (e) {
|
||||||
|
await submit.reply({ content: '❌ JSON inválido en requisitos.', flags: 64 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showRewardsModal(i: ButtonInteraction, state: QuestState, editorMsg: any) {
|
||||||
|
const modal = {
|
||||||
|
title: 'Recompensas de la Misión',
|
||||||
|
customId: 'quest_reward_modal',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.TextDisplay,
|
||||||
|
content: 'Formato JSON con coins, items, xp, etc.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Label,
|
||||||
|
label: 'Recompensas (JSON)',
|
||||||
|
component: {
|
||||||
|
type: ComponentType.TextInput,
|
||||||
|
customId: 'rewards',
|
||||||
|
style: TextInputStyle.Paragraph,
|
||||||
|
required: true,
|
||||||
|
value: JSON.stringify(state.rewards, null, 2),
|
||||||
|
placeholder: '{"coins": 500, "items": [{"key": "item.key", "quantity": 1}]}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await i.showModal(modal);
|
||||||
|
|
||||||
|
const submit = await i.awaitModalSubmit({ time: 5 * 60_000 }).catch(() => null);
|
||||||
|
if (!submit) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.rewards = JSON.parse(submit.components.getTextInputValue('rewards'));
|
||||||
|
await submit.deferUpdate();
|
||||||
|
await editorMsg.edit(createDisplay(state));
|
||||||
|
} catch (e) {
|
||||||
|
await submit.reply({ content: '❌ JSON inválido en recompensas.', flags: 64 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user