- 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.
482 lines
13 KiB
TypeScript
482 lines
13 KiB
TypeScript
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;
|
||
name?: string;
|
||
category?: string;
|
||
stats?: any; // JSON libre, ej: { attack, hp, defense }
|
||
drops?: any; // JSON libre, tabla de recompensas
|
||
}
|
||
|
||
function createMobDisplay(state: MobEditorState, editing: boolean = false) {
|
||
const title = editing ? "Editando Mob" : "Creando Mob";
|
||
const stats = state.stats || {};
|
||
return {
|
||
type: 17,
|
||
accent_color: 0xff0000,
|
||
components: [
|
||
{
|
||
type: 9,
|
||
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`,
|
||
},
|
||
],
|
||
},
|
||
{ 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`,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
export const command: CommandMessage = {
|
||
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>",
|
||
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 key = args[0]?.trim();
|
||
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 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",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
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, 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.**`,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
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,
|
||
});
|
||
}
|
||
});
|
||
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,
|
||
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 {}
|
||
}
|
||
|
||
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;
|
||
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,
|
||
});
|
||
return;
|
||
}
|
||
} else {
|
||
state[field] = {};
|
||
await sub.reply({ content: "ℹ️ Limpio.", flags: MessageFlags.Ephemeral });
|
||
}
|
||
|
||
// Refresh display
|
||
const newDisplay = createMobDisplay(state, editing);
|
||
await editorMsg.edit({
|
||
flags: 32768,
|
||
components: [
|
||
newDisplay,
|
||
{
|
||
type: 1,
|
||
components: [
|
||
{
|
||
type: 2,
|
||
style: ButtonStyle.Primary,
|
||
label: "Base",
|
||
custom_id: "mb_base",
|
||
},
|
||
{
|
||
type: 2,
|
||
style: ButtonStyle.Secondary,
|
||
label: "Stats (JSON)",
|
||
custom_id: "mb_stats",
|
||
},
|
||
{
|
||
type: 2,
|
||
style: ButtonStyle.Secondary,
|
||
label: "Drops (JSON)",
|
||
custom_id: "mb_drops",
|
||
},
|
||
{
|
||
type: 2,
|
||
style: ButtonStyle.Success,
|
||
label: "Guardar",
|
||
custom_id: "mb_save",
|
||
},
|
||
{
|
||
type: 2,
|
||
style: ButtonStyle.Danger,
|
||
label: "Cancelar",
|
||
custom_id: "mb_cancel",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
} catch {}
|
||
}
|