Refactor item property parsing and centralize utility functions

- Moved `parseItemProps` function to `core/utils.ts` for reuse across modules.
- Updated various services to import and utilize the centralized `parseItemProps`.
- Introduced new utility functions for handling consumable cooldowns and healing calculations.
- Enhanced mob management with a new repository system, allowing for dynamic loading and validation of mob definitions from the database.
- Added admin functions for creating, updating, listing, and deleting mobs, with validation using Zod.
- Implemented tests for mob management functionalities.
- Improved error handling and logging throughout the mob and consumable services.
This commit is contained in:
Shni
2025-10-14 12:57:53 -05:00
parent cd1db9d6eb
commit f36fa24e46
21 changed files with 1483 additions and 439 deletions

View File

@@ -1,19 +1,25 @@
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 type { CommandMessage } from "../../../core/types/commands";
import type Amayo from "../../../core/client";
import { hasManageGuildOrStaff } from "../../../core/lib/permissions";
import { prisma } from "../../../core/database/prisma";
export const command: CommandMessage = {
name: 'mob-eliminar',
type: 'message',
aliases: ['eliminar-mob', 'mob-delete'],
name: "mob-eliminar",
type: "message",
aliases: ["eliminar-mob", "mob-delete"],
cooldown: 5,
description: 'Eliminar un mob del servidor',
usage: 'mob-eliminar <key>',
description: "Eliminar un mob del servidor",
usage: "mob-eliminar <key>",
run: async (message, args, client: Amayo) => {
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.');
await message.reply(
"❌ No tienes permisos de ManageGuild ni rol de staff."
);
return;
}
@@ -21,23 +27,21 @@ export const command: CommandMessage = {
const key = args[0]?.trim();
if (!key) {
await message.reply('Uso: \`!mob-eliminar <key>\`\nEjemplo: \`!mob-eliminar mob.goblin\`');
await message.reply(
"Uso: `!mob-eliminar <key>`\nEjemplo: `!mob-eliminar mob.goblin`"
);
return;
}
const mob = await prisma.mob.findFirst({
where: { key, guildId }
});
if (!mob) {
await message.reply(`❌ No se encontró el mob local con key ${key} en este servidor.`);
// Use admin.deleteMob to centralize logic
const { deleteMob } = await import("../../../game/mobs/admin.js");
const deleted = await deleteMob(key);
if (!deleted) {
await message.reply(
`❌ No se encontró el mob local con key ${key} en este servidor.`
);
return;
}
await prisma.mob.delete({
where: { id: mob.id }
});
await message.reply(`✅ Mob ${key} eliminado exitosamente.`);
}
},
};

View File

@@ -1,75 +1,78 @@
import type { CommandMessage } from '../../../core/types/commands';
import type Amayo from '../../../core/client';
import { prisma } from '../../../core/database/prisma';
import { ComponentType, ButtonStyle } from 'discord-api-types/v10';
import type { MessageComponentInteraction, TextBasedChannel } from 'discord.js';
import type { CommandMessage } from "../../../core/types/commands";
import type Amayo from "../../../core/client";
import { prisma } from "../../../core/database/prisma";
import { ComponentType, ButtonStyle } from "discord-api-types/v10";
import type { MessageComponentInteraction, TextBasedChannel } from "discord.js";
export const command: CommandMessage = {
name: 'mobs-lista',
type: 'message',
aliases: ['lista-mobs', 'mobs-list'],
name: "mobs-lista",
type: "message",
aliases: ["lista-mobs", "mobs-list"],
cooldown: 5,
description: 'Ver lista de todos los mobs del servidor',
usage: 'mobs-lista [pagina]',
description: "Ver lista de todos los mobs del servidor",
usage: "mobs-lista [pagina]",
run: async (message, args, client: Amayo) => {
const guildId = message.guild!.id;
const page = parseInt(args[0]) || 1;
const perPage = 6;
const total = await prisma.mob.count({
where: { OR: [{ guildId }, { guildId: null }] }
});
const mobs = await prisma.mob.findMany({
where: { OR: [{ guildId }, { guildId: null }] },
orderBy: [{ key: 'asc' }],
skip: (page - 1) * perPage,
take: perPage
});
if (mobs.length === 0) {
await message.reply('No hay mobs configurados en este servidor.');
// Use admin list (including built-ins and DB rows)
const { listMobsWithRows } = await import("../../../game/mobs/admin.js");
const all = await listMobsWithRows();
if (!all || all.length === 0) {
await message.reply("No hay mobs configurados en este servidor.");
return;
}
const total = all.length;
const totalPages = Math.ceil(total / perPage);
const pageItems = all.slice(
(page - 1) * perPage,
(page - 1) * perPage + perPage
);
const display = {
type: 17,
accent_color: 0xFF0000,
accent_color: 0xff0000,
components: [
{
type: 9,
components: [{
type: 10,
content: `**👾 Lista de Mobs**\nPágina ${page}/${totalPages} • Total: ${total}`
}]
components: [
{
type: 10,
content: `**👾 Lista de Mobs**\nPágina ${page}/${totalPages} • Total: ${total}`,
},
],
},
{ type: 14, divider: true },
...mobs.map(mob => {
const stats = mob.stats as any || {};
...pageItems.map((entry) => {
const mob = entry.def;
const stats = (mob.base as any) || {};
return {
type: 9,
components: [{
type: 10,
content: `**${mob.name || mob.key}**\n` +
`└ Key: \`${mob.key}\`\n` +
`└ ATK: ${stats.attack || 0} | HP: ${stats.hp || 0}\n` +
`${mob.guildId === guildId ? '📍 Local' : '🌐 Global'}`
}]
components: [
{
type: 10,
content:
`**${mob.name || mob.key}**\n` +
`Key: \`${mob.key}\`\n` +
`└ ATK: ${stats.attack || 0} | HP: ${stats.hp || 0}\n` +
`${entry.guildId === guildId ? "📍 Local" : "🌐 Global"}`,
},
],
};
})
]
}),
],
};
const buttons: any[] = [];
if (page > 1) {
buttons.push({
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: '◀ Anterior',
custom_id: `mobs_prev_${page}`
label: "◀ Anterior",
custom_id: `mobs_prev_${page}`,
});
}
@@ -77,8 +80,8 @@ export const command: CommandMessage = {
buttons.push({
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: 'Siguiente ▶',
custom_id: `mobs_next_${page}`
label: "Siguiente ▶",
custom_id: `mobs_next_${page}`,
});
}
@@ -87,34 +90,38 @@ export const command: CommandMessage = {
flags: 32768,
components: [
display,
...(buttons.length > 0 ? [{
type: ComponentType.ActionRow,
components: buttons
}] : [])
]
...(buttons.length > 0
? [
{
type: ComponentType.ActionRow,
components: buttons,
},
]
: []),
],
});
const collector = msg.createMessageComponentCollector({
time: 5 * 60_000,
filter: (i) => i.user.id === message.author.id
filter: (i) => i.user.id === message.author.id,
});
collector.on('collect', async (i: MessageComponentInteraction) => {
collector.on("collect", async (i: MessageComponentInteraction) => {
if (!i.isButton()) return;
if (i.customId.startsWith('mobs_prev_')) {
const currentPage = parseInt(i.customId.split('_')[2]);
if (i.customId.startsWith("mobs_prev_")) {
const currentPage = parseInt(i.customId.split("_")[2]);
await i.deferUpdate();
args[0] = String(currentPage - 1);
await command.run!(message, args, client);
collector.stop();
} else if (i.customId.startsWith('mobs_next_')) {
const currentPage = parseInt(i.customId.split('_')[2]);
} else if (i.customId.startsWith("mobs_next_")) {
const currentPage = parseInt(i.customId.split("_")[2]);
await i.deferUpdate();
args[0] = String(currentPage + 1);
await command.run!(message, args, client);
collector.stop();
}
});
}
},
};

View File

@@ -271,6 +271,8 @@ export interface KeyPickerResult<T> {
entry: T | null;
panelMessage: Message | null;
reason: "selected" | "empty" | "cancelled" | "timeout";
// When present, the raw value selected from the select menu (may be id or key)
selectedValue?: string;
}
export async function promptKeySelection<T>(
@@ -444,11 +446,12 @@ export async function promptKeySelection<T>(
const result = await new Promise<KeyPickerResult<T>>((resolve) => {
const finish = (
entry: T | null,
reason: "selected" | "cancelled" | "timeout"
reason: "selected" | "cancelled" | "timeout",
selectedValue?: string
) => {
if (resolved) return;
resolved = true;
resolve({ entry, panelMessage, reason });
resolve({ entry, panelMessage, reason, selectedValue });
};
const collector = panelMessage.createMessageComponentCollector({
@@ -501,7 +504,7 @@ export async function promptKeySelection<T>(
}
}
finish(selected.entry, "selected");
finish(selected.entry, "selected", value);
collector.stop("selected");
return;
}

View File

@@ -16,10 +16,7 @@ import { sendDisplayReply, formatItemLabel } from "./_helpers";
const PAGE_SIZE = 15;
function parseItemProps(json: unknown): ItemProps {
if (!json || typeof json !== "object") return {};
return json as ItemProps;
}
import { parseItemProps } from "../../../game/core/utils";
function fmtTool(props: ItemProps) {
const t = props.tool;

View File

@@ -1,9 +1,19 @@
import { Message, MessageFlags, MessageComponentInteraction, ButtonInteraction, TextBasedChannel } from 'discord.js';
import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10';
import type { CommandMessage } from '../../../core/types/commands';
import { hasManageGuildOrStaff } from '../../../core/lib/permissions';
import logger from '../../../core/lib/logger';
import type Amayo from '../../../core/client';
import {
Message,
MessageFlags,
MessageComponentInteraction,
ButtonInteraction,
TextBasedChannel,
} from "discord.js";
import {
ComponentType,
TextInputStyle,
ButtonStyle,
} from "discord-api-types/v10";
import type { CommandMessage } from "../../../core/types/commands";
import { hasManageGuildOrStaff } from "../../../core/lib/permissions";
import logger from "../../../core/lib/logger";
import type Amayo from "../../../core/client";
interface MobEditorState {
key: string;
@@ -14,151 +24,321 @@ interface MobEditorState {
}
function createMobDisplay(state: MobEditorState, editing: boolean = false) {
const title = editing ? 'Editando Mob' : 'Creando Mob';
const title = editing ? "Editando Mob" : "Creando Mob";
const stats = state.stats || {};
return {
type: 17,
accent_color: 0xFF0000,
accent_color: 0xff0000,
components: [
{
type: 9,
components: [{
type: 10,
content: `👹 **${title}: \`${state.key}\`**`
}]
components: [
{
type: 10,
content: `👹 **${title}: \`${state.key}\`**`,
},
],
},
{ type: 14, divider: true },
{
type: 9,
components: [{
type: 10,
content: `**📋 Estado Actual:**\n` +
`**Nombre:** ${state.name || '❌ No configurado'}\n` +
`**Categoría:** ${state.category || 'Sin categoría'}\n` +
`**Attack:** ${stats.attack || 0}\n` +
`**HP:** ${stats.hp || 0}\n` +
`**Defense:** ${stats.defense || 0}\n` +
`**Drops:** ${Object.keys(state.drops || {}).length} items`
}]
components: [
{
type: 10,
content:
`**📋 Estado Actual:**\n` +
`**Nombre:** ${state.name || "❌ No configurado"}\n` +
`**Categoría:** ${state.category || "Sin categoría"}\n` +
`**Attack:** ${stats.attack || 0}\n` +
`**HP:** ${stats.hp || 0}\n` +
`**Defense:** ${stats.defense || 0}\n` +
`**Drops:** ${Object.keys(state.drops || {}).length} items`,
},
],
},
{ type: 14, divider: true },
{
type: 9,
components: [{
type: 10,
content: `**🎮 Instrucciones:**\n` +
`• **Base**: Nombre y categoría\n` +
`• **Stats (JSON)**: Estadísticas del mob\n` +
`• **Drops (JSON)**: Items que dropea\n` +
`• **Guardar**: Confirma los cambios\n` +
`• **Cancelar**: Descarta los cambios`
}]
}
]
components: [
{
type: 10,
content:
`**🎮 Instrucciones:**\n` +
`• **Base**: Nombre y categoría\n` +
`• **Stats (JSON)**: Estadísticas del mob\n` +
`• **Drops (JSON)**: Items que dropea\n` +
`• **Guardar**: Confirma los cambios\n` +
`• **Cancelar**: Descarta los cambios`,
},
],
},
],
};
}
export const command: CommandMessage = {
name: 'mob-crear',
type: 'message',
aliases: ['crear-mob','mobcreate'],
name: "mob-crear",
type: "message",
aliases: ["crear-mob", "mobcreate"],
cooldown: 10,
description: 'Crea un Mob (enemigo) para este servidor con editor interactivo.',
category: 'Minijuegos',
usage: 'mob-crear <key-única>',
description:
"Crea un Mob (enemigo) para este servidor con editor interactivo.",
category: "Minijuegos",
usage: "mob-crear <key-única>",
run: async (message: Message, args: string[], client: Amayo) => {
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, client.prisma);
if (!allowed) { await message.reply('❌ No tienes permisos de ManageGuild ni rol de staff.'); return; }
const allowed = await hasManageGuildOrStaff(
message.member,
message.guild!.id,
client.prisma
);
if (!allowed) {
await message.reply(
"❌ No tienes permisos de ManageGuild ni rol de staff."
);
return;
}
const key = args[0]?.trim();
if (!key) { await message.reply('Uso: `!mob-crear <key-única>`'); return; }
if (!key) {
await message.reply("Uso: `!mob-crear <key-única>`");
return;
}
const guildId = message.guild!.id;
const exists = await client.prisma.mob.findFirst({ where: { key, guildId } });
if (exists) { await message.reply('❌ Ya existe un mob con esa key.'); return; }
const exists = await client.prisma.mob.findFirst({
where: { key, guildId },
});
if (exists) {
await message.reply("❌ Ya existe un mob con esa key.");
return;
}
const state: MobEditorState = { key, stats: { attack: 5 }, drops: {} };
const channel = message.channel as TextBasedChannel & { send: Function };
const editorMsg = await channel.send({
content: `👾 Editor de Mob: \`${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' },
] } ],
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) => {
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') {
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.**'
}]
}]
}]
components: [
{
type: 17,
accent_color: 0xff0000,
components: [
{
type: 9,
components: [
{
type: 10,
content: "**❌ Editor cancelado.**",
},
],
},
],
},
],
});
collector.stop('cancel');
collector.stop("cancel");
return;
}
if (i.customId === 'mb_base') { await showBaseModal(i as ButtonInteraction, state, editorMsg, false); return; }
if (i.customId === 'mb_stats') { await showJsonModal(i as ButtonInteraction, state, 'stats', 'Stats del Mob (JSON)', editorMsg, false); return; }
if (i.customId === 'mb_drops') { await showJsonModal(i as ButtonInteraction, state, 'drops', 'Drops del Mob (JSON)', editorMsg, false); 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.create({ data: { guildId, key: state.key, name: state.name!, category: state.category ?? null, stats: state.stats ?? {}, drops: state.drops ?? {} } });
await i.reply({ content: '✅ Mob guardado!', flags: MessageFlags.Ephemeral });
if (i.customId === "mb_base") {
await showBaseModal(i as ButtonInteraction, state, editorMsg, false);
return;
}
if (i.customId === "mb_stats") {
await showJsonModal(
i as ButtonInteraction,
state,
"stats",
"Stats del Mob (JSON)",
editorMsg,
false
);
return;
}
if (i.customId === "mb_drops") {
await showJsonModal(
i as ButtonInteraction,
state,
"drops",
"Drops del Mob (JSON)",
editorMsg,
false
);
return;
}
if (i.customId === "mb_save") {
if (!state.name) {
await i.reply({
content: "❌ Falta el nombre del mob.",
flags: MessageFlags.Ephemeral,
});
return;
}
// Use centralized admin createOrUpdate to persist mob (returns row when possible)
try {
const { createOrUpdateMob } = await import(
"../../../game/mobs/admin.js"
);
await createOrUpdateMob({ ...(state as any), guildId });
await i.reply({
content: "✅ Mob guardado!",
flags: MessageFlags.Ephemeral,
});
} catch (e) {
// fallback to direct Prisma if admin module not available
await client.prisma.mob.create({
data: {
guildId,
key: state.key,
name: state.name!,
category: state.category ?? null,
stats: state.stats ?? {},
drops: state.drops ?? {},
},
});
await i.reply({
content: "✅ Mob guardado (fallback)!",
flags: MessageFlags.Ephemeral,
});
}
await editorMsg.edit({
flags: 32768,
components: [{
type: 17,
accent_color: 0x00FF00,
components: [{
type: 9,
components: [{
type: 10,
content: `**✅ Mob \`${state.key}\` creado exitosamente.**`
}]
}]
}]
components: [
{
type: 17,
accent_color: 0x00ff00,
components: [
{
type: 9,
components: [
{
type: 10,
content: `**✅ Mob \`${state.key}\` creado exitosamente.**`,
},
],
},
],
},
],
});
collector.stop('saved');
collector.stop("saved");
return;
}
} catch (err) {
logger.error({err}, 'mob-crear');
if (!i.deferred && !i.replied) await i.reply({ content: '❌ Error procesando la acción.', flags: MessageFlags.Ephemeral });
logger.error({ err }, "mob-crear");
if (!i.deferred && !i.replied)
await i.reply({
content: "❌ Error procesando la acción.",
flags: MessageFlags.Ephemeral,
});
}
});
collector.on("end", async (_c, r) => {
if (r === "time") {
try {
await editorMsg.edit({
content: "⏰ Editor expirado.",
components: [],
});
} catch {}
}
});
collector.on('end', async (_c,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;
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.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 });
await sub.reply({
content: "✅ Base actualizada.",
flags: MessageFlags.Ephemeral,
});
// Refresh display
const newDisplay = createMobDisplay(state, editing);
await editorMsg.edit({
@@ -168,40 +348,92 @@ async function showBaseModal(i: ButtonInteraction, state: MobEditorState, editor
{
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' },
]
}
]
{
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 {}
}
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,
editing: boolean
) {
const current = JSON.stringify(state[field] ?? {});
const modal = { title, customId: `mb_json_${field}`, components: [
{ type: ComponentType.Label, label: 'JSON', component: { type: ComponentType.TextInput, customId: 'json', style: TextInputStyle.Paragraph, required: false, value: current.slice(0,4000) } },
] } as const;
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),
},
},
],
} as const;
await i.showModal(modal);
try {
const sub = await i.awaitModalSubmit({ time: 300_000 });
const raw = sub.components.getTextInputValue('json');
const raw = sub.components.getTextInputValue("json");
if (raw) {
try {
state[field] = JSON.parse(raw);
await sub.reply({ content: '✅ Guardado.', flags: MessageFlags.Ephemeral });
await sub.reply({
content: "✅ Guardado.",
flags: MessageFlags.Ephemeral,
});
} catch {
await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral });
await sub.reply({
content: "❌ JSON inválido.",
flags: MessageFlags.Ephemeral,
});
return;
}
} else {
state[field] = {};
await sub.reply({ content: ' Limpio.', flags: MessageFlags.Ephemeral });
await sub.reply({ content: " Limpio.", flags: MessageFlags.Ephemeral });
}
// Refresh display
const newDisplay = createMobDisplay(state, editing);
await editorMsg.edit({
@@ -211,14 +443,39 @@ async function showJsonModal(i: ButtonInteraction, state: MobEditorState, field:
{
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' },
]
}
]
{
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 {}
}

View File

@@ -1,10 +1,20 @@
import { Message, MessageFlags, MessageComponentInteraction, ButtonInteraction, TextBasedChannel } from 'discord.js';
import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10';
import type { CommandMessage } from '../../../core/types/commands';
import { hasManageGuildOrStaff } from '../../../core/lib/permissions';
import logger from '../../../core/lib/logger';
import type Amayo from '../../../core/client';
import { promptKeySelection } from './_helpers';
import {
Message,
MessageFlags,
MessageComponentInteraction,
ButtonInteraction,
TextBasedChannel,
} from "discord.js";
import {
ComponentType,
TextInputStyle,
ButtonStyle,
} from "discord-api-types/v10";
import type { CommandMessage } from "../../../core/types/commands";
import { hasManageGuildOrStaff } from "../../../core/lib/permissions";
import logger from "../../../core/lib/logger";
import type Amayo from "../../../core/client";
import { promptKeySelection } from "./_helpers";
interface MobEditorState {
key: string;
@@ -14,84 +24,103 @@ interface MobEditorState {
drops?: any;
}
function createMobDisplay(state: MobEditorState, editing: boolean = false) {
const title = editing ? 'Editando Mob' : 'Creando Mob';
const title = editing ? "Editando Mob" : "Creando Mob";
const stats = state.stats || {};
return {
type: 17,
accent_color: 0xFF0000,
accent_color: 0xff0000,
components: [
{ type: 10, content: `# 👹 ${title}: \`${state.key}\`` },
{ type: 14, divider: true },
{
type: 10,
content: [
'**📋 Estado Actual:**',
`**Nombre:** ${state.name || '❌ No configurado'}`,
`**Categoría:** ${state.category || 'Sin categoría'}`,
"**📋 Estado Actual:**",
`**Nombre:** ${state.name || "❌ No configurado"}`,
`**Categoría:** ${state.category || "Sin categoría"}`,
`**Attack:** ${stats.attack || 0}`,
`**HP:** ${stats.hp || 0}`,
`**Defense:** ${stats.defense || 0}`,
`**Drops:** ${Object.keys(state.drops || {}).length} items`,
].join('\n'),
].join("\n"),
},
{ type: 14, divider: true },
{
type: 10,
content: [
'**🎮 Instrucciones:**',
'• **Base**: Nombre y categoría',
'• **Stats (JSON)**: Estadísticas del mob',
'• **Drops (JSON)**: Items que dropea',
'• **Guardar**: Confirma los cambios',
'• **Cancelar**: Descarta los cambios',
].join('\n'),
"**🎮 Instrucciones:**",
"• **Base**: Nombre y categoría",
"• **Stats (JSON)**: Estadísticas del mob",
"• **Drops (JSON)**: Items que dropea",
"• **Guardar**: Confirma los cambios",
"• **Cancelar**: Descarta los cambios",
].join("\n"),
},
]
],
};
}
export const command: CommandMessage = {
name: 'mob-editar',
type: 'message',
aliases: ['editar-mob','mobedit'],
name: "mob-editar",
type: "message",
aliases: ["editar-mob", "mobedit"],
cooldown: 10,
description: 'Edita un Mob (enemigo) de este servidor con editor interactivo.',
category: 'Minijuegos',
usage: 'mob-editar',
description:
"Edita un Mob (enemigo) de este servidor con editor interactivo.",
category: "Minijuegos",
usage: "mob-editar",
run: async (message: Message, _args: string[], client: Amayo) => {
const channel = message.channel as TextBasedChannel & { send: Function };
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, client.prisma);
const allowed = await hasManageGuildOrStaff(
message.member,
message.guild!.id,
client.prisma
);
if (!allowed) {
await (channel.send as any)({
content: null,
flags: 32768,
components: [{
type: 17,
accent_color: 0xFF0000,
components: [{
type: 10,
content: '❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.'
}]
}],
reply: { messageReference: message.id }
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 mobs = await client.prisma.mob.findMany({ where: { guildId }, orderBy: [{ key: 'asc' }] });
const { listMobsWithRows } = await import("../../../game/mobs/admin.js");
const all = await listMobsWithRows();
// Keep behaviour: only guild-local mobs editable here
const localEntries = all.filter((e: any) => e.guildId === guildId);
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 ?? ''],
entries: localEntries,
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: (entry: any) => ({
value: entry.id ?? entry.def.key,
label: entry.def.name ?? entry.def.key,
description: [entry.def?.category ?? "Sin categoría", entry.def.key]
.filter(Boolean)
.join(" • "),
keywords: [
entry.def.key,
entry.def.name ?? "",
entry.def?.category ?? "",
],
}),
});
@@ -99,14 +128,23 @@ export const command: CommandMessage = {
return;
}
const mob = selection.entry;
const entry = selection.entry as any;
if (!entry) return;
// If entry has an id (DB row), fetch the full row to get stats/drops stored in DB.
let dbRow: any = null;
if (entry.id) {
try {
dbRow = await client.prisma.mob.findUnique({ where: { id: entry.id } });
} catch {}
}
const state: MobEditorState = {
key: mob.key,
name: mob.name,
category: mob.category ?? undefined,
stats: mob.stats ?? {},
drops: mob.drops ?? {},
key: entry.def.key,
name: (dbRow && dbRow.name) ?? entry.def.name,
category: (dbRow && dbRow.category) ?? entry.def?.category ?? undefined,
stats: (dbRow && dbRow.stats) ?? entry.def?.base ?? {},
drops: (dbRow && dbRow.drops) ?? entry.def?.drops ?? {},
};
const buildEditorComponents = () => [
@@ -114,13 +152,38 @@ export const command: CommandMessage = {
{
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' },
]
}
{
type: 2,
style: ButtonStyle.Primary,
label: "Base",
custom_id: "mb_base",
},
{
type: 2,
style: ButtonStyle.Secondary,
label: "Stats (JSON)",
custom_id: "mb_stats",
},
{
type: 2,
style: ButtonStyle.Secondary,
label: "Drops (JSON)",
custom_id: "mb_drops",
},
{
type: 2,
style: ButtonStyle.Success,
label: "Guardar",
custom_id: "mb_save",
},
{
type: 2,
style: ButtonStyle.Danger,
label: "Cancelar",
custom_id: "mb_cancel",
},
],
},
];
const editorMsg = selection.panelMessage;
@@ -130,77 +193,142 @@ export const command: CommandMessage = {
components: buildEditorComponents(),
});
const collector = editorMsg.createMessageComponentCollector({ time: 30 * 60_000, filter: (i) => i.user.id === message.author.id });
collector.on('collect', async (i: MessageComponentInteraction) => {
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':
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.**'
}]
}]
components: [
{
type: 17,
accent_color: 0xff0000,
components: [
{
type: 10,
content: "**❌ Editor cancelado.**",
},
],
},
],
});
collector.stop('cancel');
collector.stop("cancel");
return;
case 'mb_base':
await showBaseModal(i as ButtonInteraction, state, editorMsg, buildEditorComponents);
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);
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);
case "mb_drops":
await showJsonModal(
i as ButtonInteraction,
state,
"drops",
"Drops del Mob (JSON)",
editorMsg,
buildEditorComponents
);
return;
case 'mb_save':
case "mb_save":
if (!state.name) {
await i.reply({ content: '❌ Falta el nombre del mob.', flags: MessageFlags.Ephemeral });
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 });
try {
const { createOrUpdateMob } = await import(
"../../../game/mobs/admin.js"
);
// Provide guildId so admin can scope or return db row
await createOrUpdateMob({ ...(state as any), guildId });
await i.reply({
content: "✅ Mob actualizado!",
flags: MessageFlags.Ephemeral,
});
} catch (e) {
// fallback to direct update
await client.prisma.mob.update({
where: { id: entry.id },
data: {
name: state.name!,
category: state.category ?? null,
stats: state.stats ?? {},
drops: state.drops ?? {},
},
});
await i.reply({
content: "✅ Mob actualizado (fallback)!",
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.**`
}]
}]
components: [
{
type: 17,
accent_color: 0x00ff00,
components: [
{
type: 10,
content: `**✅ Mob \`${state.key}\` actualizado exitosamente.**`,
},
],
},
],
});
collector.stop('saved');
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 });
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') {
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.**'
}]
}]
components: [
{
type: 17,
accent_color: 0xffa500,
components: [
{
type: 10,
content: "**⏰ Editor expirado.**",
},
],
},
],
});
} catch {}
}
@@ -208,41 +336,94 @@ export const command: CommandMessage = {
},
};
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;
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.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()
components: buildComponents(),
});
} catch {}
}
async function showJsonModal(i: ButtonInteraction, state: MobEditorState, field: 'stats'|'drops', title: string, editorMsg: Message, buildComponents: () => any[]) {
async function showJsonModal(
i: ButtonInteraction,
state: MobEditorState,
field: "stats" | "drops",
title: string,
editorMsg: Message,
buildComponents: () => any[]
) {
const current = JSON.stringify(state[field] ?? {});
const modal = { title, customId: `mb_json_${field}`, components: [
{ type: ComponentType.Label, label: 'JSON', component: { type: ComponentType.TextInput, customId: 'json', style: TextInputStyle.Paragraph, required: false, value: current.slice(0,4000) } },
] } as const;
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),
},
},
],
} as const;
await i.showModal(modal);
try {
const sub = await i.awaitModalSubmit({ time: 300_000 });
const raw = sub.components.getTextInputValue('json');
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 });
await sub.reply({
content: "❌ JSON inválido.",
flags: MessageFlags.Ephemeral,
});
return;
}
} else {
@@ -252,7 +433,7 @@ async function showJsonModal(i: ButtonInteraction, state: MobEditorState, field:
await editorMsg.edit({
content: null,
flags: 32768,
components: buildComponents()
components: buildComponents(),
});
} catch {}
}

View File

@@ -36,10 +36,7 @@ function buildEmoji(
return { id, name, animated };
}
function parseItemProps(json: unknown): ItemProps {
if (!json || typeof json !== "object") return {};
return json as ItemProps;
}
import { parseItemProps } from "../../../game/core/utils";
function formatPrice(price: any): string {
const parts: string[] = [];