diff --git a/package.json b/package.json index 7319af1..2e85000 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "tsc": "tsc", "typecheck": "tsc --noEmit", "seed:minigames": "npx tsx src/game/minigames/seed.ts", + "test:mobs": "npx tsx scripts/testMobData.ts", "start:optimize-relic": "NODE_ENV=production ENABLE_MEMORY_OPTIMIZER=true NEW_RELIC_APP_NAME=amayo NEW_RELIC_LICENSE_KEY= NODE_OPTIONS='--max-old-space-size=512 --expose-gc' npx tsx --experimental-loader=newrelic/esm-loader.mjs src/main.ts" }, "keywords": [], @@ -35,7 +36,8 @@ "ejs": "^3.1.10", "newrelic": "13.4.0", "node-appwrite": "19.1.0", - "pino": "9.13.0", + "pino": "9.13.0", + "zod": "4.25.1", "prisma": "6.16.2", "redis": "5.8.2" }, diff --git a/scripts/mobAdminTest.ts b/scripts/mobAdminTest.ts new file mode 100644 index 0000000..7d0fe86 --- /dev/null +++ b/scripts/mobAdminTest.ts @@ -0,0 +1,41 @@ +import { + createOrUpdateMob, + listMobs, + getMob, + deleteMob, + ensureMobRepoUpToDate, +} from "../src/game/mobs/admin"; + +async function run() { + console.log("Ensuring repo up-to-date..."); + await ensureMobRepoUpToDate(); + + const testMob = { + key: "test.goblin", + name: "Goblin Test", + tier: 1, + base: { hp: 12, attack: 3 }, + } as any; + + console.log("Creating test mob..."); + const created = await createOrUpdateMob(testMob); + console.log("Created:", created.key); + + console.log("Listing mobs (sample):"); + const all = await listMobs(); + console.log(`Total mobs: ${all.length}`); + console.log(all.map((m) => m.key).join(", ")); + + console.log("Fetching test.mob..."); + const fetched = await getMob("test.goblin"); + console.log("Fetched:", !!fetched, fetched ? fetched : "(no data)"); + + console.log("Deleting test mob..."); + const deleted = await deleteMob("test.goblin"); + console.log("Deleted?", deleted); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/testMobData.ts b/scripts/testMobData.ts new file mode 100644 index 0000000..d41d611 --- /dev/null +++ b/scripts/testMobData.ts @@ -0,0 +1,18 @@ +import { + initializeMobRepository, + getMobInstance, + listMobKeys, +} from "../src/game/mobs/mobData"; + +async function run() { + console.log("Initializing mob repository..."); + await initializeMobRepository(); + console.log("Available mob keys:", listMobKeys()); + const inst = getMobInstance("slime.green", 3); + console.log("Sample slime.green @ lvl3 ->", inst); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/commands/messages/admin/mobEliminar.ts b/src/commands/messages/admin/mobEliminar.ts index 3bfb3c7..e9bec9f 100644 --- a/src/commands/messages/admin/mobEliminar.ts +++ b/src/commands/messages/admin/mobEliminar.ts @@ -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 ', + description: "Eliminar un mob del servidor", + usage: "mob-eliminar ", 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 \`\nEjemplo: \`!mob-eliminar mob.goblin\`'); + await message.reply( + "Uso: `!mob-eliminar `\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.`); - } + }, }; diff --git a/src/commands/messages/admin/mobsLista.ts b/src/commands/messages/admin/mobsLista.ts index 424bf22..6966073 100644 --- a/src/commands/messages/admin/mobsLista.ts +++ b/src/commands/messages/admin/mobsLista.ts @@ -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(); } }); - } + }, }; diff --git a/src/commands/messages/game/_helpers.ts b/src/commands/messages/game/_helpers.ts index f85e587..addf934 100644 --- a/src/commands/messages/game/_helpers.ts +++ b/src/commands/messages/game/_helpers.ts @@ -271,6 +271,8 @@ export interface KeyPickerResult { 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( @@ -444,11 +446,12 @@ export async function promptKeySelection( const result = await new Promise>((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( } } - finish(selected.entry, "selected"); + finish(selected.entry, "selected", value); collector.stop("selected"); return; } diff --git a/src/commands/messages/game/inventario.ts b/src/commands/messages/game/inventario.ts index c74e3d8..f7e45cf 100644 --- a/src/commands/messages/game/inventario.ts +++ b/src/commands/messages/game/inventario.ts @@ -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; diff --git a/src/commands/messages/game/mobCreate.ts b/src/commands/messages/game/mobCreate.ts index 043c57e..20a869c 100644 --- a/src/commands/messages/game/mobCreate.ts +++ b/src/commands/messages/game/mobCreate.ts @@ -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 ', + description: + "Crea un Mob (enemigo) para este servidor con editor interactivo.", + category: "Minijuegos", + usage: "mob-crear ", 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 `'); return; } + if (!key) { + await message.reply("Uso: `!mob-crear `"); + 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 {} } diff --git a/src/commands/messages/game/mobEdit.ts b/src/commands/messages/game/mobEdit.ts index 92452f8..613e6de 100644 --- a/src/commands/messages/game/mobEdit.ts +++ b/src/commands/messages/game/mobEdit.ts @@ -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 {} } diff --git a/src/commands/messages/game/tienda.ts b/src/commands/messages/game/tienda.ts index ad7a00d..75c7cb8 100644 --- a/src/commands/messages/game/tienda.ts +++ b/src/commands/messages/game/tienda.ts @@ -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[] = []; diff --git a/src/game/combat/equipmentService.ts b/src/game/combat/equipmentService.ts index 136dbc9..ba150cd 100644 --- a/src/game/combat/equipmentService.ts +++ b/src/game/combat/equipmentService.ts @@ -5,11 +5,7 @@ import { } from "./statusEffectsService"; import type { ItemProps } from "../economy/types"; import { ensureUserAndGuildExist } from "../core/userService"; - -function parseItemProps(json: unknown): ItemProps { - if (!json || typeof json !== "object") return {}; - return json as ItemProps; -} +import { parseItemProps } from "../core/utils"; export async function ensurePlayerState(userId: string, guildId: string) { // Asegurar que User y Guild existan antes de crear/buscar state diff --git a/src/game/consumables/service.ts b/src/game/consumables/service.ts index 868638d..1fa6e81 100644 --- a/src/game/consumables/service.ts +++ b/src/game/consumables/service.ts @@ -1,34 +1,32 @@ -import { prisma } from '../../core/database/prisma'; -import { assertNotOnCooldown, setCooldown } from '../cooldowns/service'; -import { findItemByKey, consumeItemByKey } from '../economy/service'; -import type { ItemProps } from '../economy/types'; -import { getEffectiveStats, adjustHP } from '../combat/equipmentService'; +import { prisma } from "../../core/database/prisma"; +import { assertNotOnCooldown, setCooldown } from "../cooldowns/service"; +import { findItemByKey, consumeItemByKey } from "../economy/service"; +import type { ItemProps } from "../economy/types"; +import { getEffectiveStats, adjustHP } from "../combat/equipmentService"; +import { parseItemProps } from "../core/utils"; +import { getCooldownKeyForFood, calculateHealingFromFood } from "./utils"; -function parseItemProps(json: unknown): ItemProps { - if (!json || typeof json !== 'object') return {}; - return json as ItemProps; -} - -export async function useConsumableByKey(userId: string, guildId: string, itemKey: string) { +export async function useConsumableByKey( + userId: string, + guildId: string, + itemKey: string +) { const item = await findItemByKey(guildId, itemKey); - if (!item) throw new Error('Ítem no encontrado'); + if (!item) throw new Error("Ítem no encontrado"); const props = parseItemProps(item.props); const food = props.food; - if (!food) throw new Error('Este ítem no es consumible'); + if (!food) throw new Error("Este ítem no es consumible"); - const cdKey = food.cooldownKey ?? `food:${item.key}`; + const cdKey = getCooldownKeyForFood(item.key, food); await assertNotOnCooldown(userId, guildId, cdKey); // Calcular sanación const stats = await getEffectiveStats(userId, guildId); - const flat = Math.max(0, food.healHp ?? 0); - const perc = Math.max(0, food.healPercent ?? 0); - const byPerc = Math.floor((perc / 100) * stats.maxHp); - const heal = Math.max(1, flat + byPerc); + const heal = calculateHealingFromFood(food, stats.maxHp); // Consumir el ítem const { consumed } = await consumeItemByKey(userId, guildId, item.key, 1); - if (consumed <= 0) throw new Error('No tienes este ítem'); + if (consumed <= 0) throw new Error("No tienes este ítem"); // Aplicar curación await adjustHP(userId, guildId, heal); @@ -40,4 +38,3 @@ export async function useConsumableByKey(userId: string, guildId: string, itemKe return { healed: heal } as const; } - diff --git a/src/game/consumables/utils.ts b/src/game/consumables/utils.ts new file mode 100644 index 0000000..2916cb3 --- /dev/null +++ b/src/game/consumables/utils.ts @@ -0,0 +1,16 @@ +export function getCooldownKeyForFood( + itemKey: string, + foodProps: any | undefined +) { + return foodProps?.cooldownKey ?? `food:${itemKey}`; +} + +export function calculateHealingFromFood( + foodProps: any | undefined, + maxHp: number +) { + const flat = Math.max(0, (foodProps?.healHp ?? 0) as number); + const perc = Math.max(0, (foodProps?.healPercent ?? 0) as number); + const byPerc = Math.floor((perc / 100) * maxHp); + return Math.max(1, flat + byPerc); +} diff --git a/src/game/core/utils.ts b/src/game/core/utils.ts new file mode 100644 index 0000000..9adfb89 --- /dev/null +++ b/src/game/core/utils.ts @@ -0,0 +1,55 @@ +import { prisma } from "../../core/database/prisma"; +import type { Prisma } from "@prisma/client"; + +export function now(): Date { + return new Date(); +} + +export function isWithin( + date: Date, + from?: Date | null, + to?: Date | null +): boolean { + if (from && date < from) return false; + if (to && date > to) return false; + return true; +} + +export function ensureArray(v: T[] | undefined | null): T[] { + return Array.isArray(v) ? v : []; +} + +export function parseItemProps(json: unknown): any { + if (!json || typeof json !== "object") return {}; + return json as any; +} + +export function parseState(json: unknown): any { + if (!json || typeof json !== "object") return {}; + return json as any; +} + +export async function updateInventoryEntryState( + userId: string, + guildId: string, + itemId: string, + state: any +) { + const quantity = + state.instances && Array.isArray(state.instances) + ? state.instances.length + : 0; + return prisma.inventoryEntry.update({ + where: { userId_guildId_itemId: { userId, guildId, itemId } }, + data: { state: state as unknown as Prisma.InputJsonValue, quantity }, + }); +} + +export default { + now, + isWithin, + ensureArray, + parseItemProps, + parseState, + updateInventoryEntryState, +}; diff --git a/src/game/economy/service.ts b/src/game/economy/service.ts index ca97efb..98f5a91 100644 --- a/src/game/economy/service.ts +++ b/src/game/economy/service.ts @@ -7,17 +7,17 @@ import type { } from "./types"; import type { Prisma } from "@prisma/client"; import { ensureUserAndGuildExist } from "../core/userService"; +import coreUtils from "../core/utils"; -// Utilidades de tiempo -function now(): Date { - return new Date(); -} - -function isWithin(date: Date, from?: Date | null, to?: Date | null): boolean { - if (from && date < from) return false; - if (to && date > to) return false; - return true; -} +// Reusar utilidades centrales desde game core +const { + now, + isWithin, + ensureArray, + parseItemProps, + parseState, + updateInventoryEntryState, +} = coreUtils as any; // Resuelve un EconomyItem por key con alcance de guild o global export async function findItemByKey(guildId: string, key: string) { @@ -89,15 +89,8 @@ export async function getInventoryEntry( return { item, entry } as const; } -function parseItemProps(json: unknown): ItemProps { - if (!json || typeof json !== "object") return {}; - return json as ItemProps; -} - -function parseState(json: unknown): InventoryState { - if (!json || typeof json !== "object") return {}; - return json as InventoryState; -} +// utilidades parseItemProps, parseState, ensureArray y updateInventoryEntryState +// provistas por coreUtils importado arriba function checkUsableWindow(item: { usableFrom: Date | null; @@ -156,7 +149,7 @@ export async function addItemByKey( } else { // No apilable: usar state.instances const state = parseState(entry.state); - state.instances ??= []; + state.instances = ensureArray(state.instances); const canAdd = Math.max( 0, Math.min(qty, Math.max(0, max - state.instances.length)) @@ -173,13 +166,12 @@ export async function addItemByKey( state.instances.push({}); } } - const updated = await prisma.inventoryEntry.update({ - where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, - data: { - state: state as unknown as Prisma.InputJsonValue, - quantity: state.instances.length, - }, - }); + const updated = await updateInventoryEntryState( + userId, + guildId, + item.id, + state + ); return { added: canAdd, entry: updated } as const; } } @@ -203,18 +195,16 @@ export async function consumeItemByKey( return { consumed, entry: updated } as const; } else { const state = parseState(entry.state); - const instances = state.instances ?? []; - const consumed = Math.min(qty, instances.length); + state.instances = ensureArray(state.instances); + const consumed = Math.min(qty, state.instances.length); if (consumed === 0) return { consumed: 0 } as const; - instances.splice(0, consumed); - const newState: InventoryState = { ...state, instances }; - const updated = await prisma.inventoryEntry.update({ - where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, - data: { - state: newState as unknown as Prisma.InputJsonValue, - quantity: instances.length, - }, - }); + state.instances.splice(0, consumed); + const updated = await updateInventoryEntryState( + userId, + guildId, + item.id, + state + ); return { consumed, entry: updated } as const; } } @@ -232,7 +222,7 @@ export async function openChestByKey( const props = parseItemProps(item.props); const chest = props.chest ?? {}; if (!chest.enabled) throw new Error("Este ítem no se puede abrir"); - const rewards = Array.isArray(chest.rewards) ? chest.rewards : []; + const rewards: any[] = Array.isArray(chest.rewards) ? chest.rewards : []; const mode = chest.randomMode || "all"; const result: OpenChestResult = { coinsDelta: 0, diff --git a/src/game/minigames/service.ts b/src/game/minigames/service.ts index d0ba31e..97f501c 100644 --- a/src/game/minigames/service.ts +++ b/src/game/minigames/service.ts @@ -19,6 +19,7 @@ import { import { logToolBreak } from "../lib/toolBreakLog"; import { updateStats } from "../stats/service"; // 🟩 local authoritative import type { ItemProps, InventoryState } from "../economy/types"; +import { parseItemProps, parseState as parseInvState } from "../core/utils"; import type { LevelRequirements, RunMinigameOptions, @@ -120,15 +121,7 @@ async function ensureAreaAndLevel( return { area, lvl } as const; } -function parseItemProps(json: unknown): ItemProps { - if (!json || typeof json !== "object") return {}; - return json as ItemProps; -} - -function parseInvState(json: unknown): InventoryState { - if (!json || typeof json !== "object") return {}; - return json as InventoryState; -} +// parseItemProps y parseInvState son importados desde ../core/utils para centralizar parsing async function validateRequirements( userId: string, @@ -280,6 +273,26 @@ async function sampleMobs(mobs?: MobsTable): Promise { return out; } +// Devuelve instancias de mobs escaladas por nivel (usa getMobInstance de mobData) +import { getMobInstance } from "../mobs/mobData"; + +async function sampleMobInstances( + mobs?: MobsTable, + areaLevel = 1 +): Promise[]> { + const out: ReturnType[] = []; + if (!mobs || !Array.isArray(mobs.table) || mobs.table.length === 0) + return out; + const draws = Math.max(0, mobs.draws ?? 0); + for (let i = 0; i < draws; i++) { + const pick = pickWeighted(mobs.table); + if (!pick) continue; + const inst = getMobInstance(pick.mobKey, areaLevel); + if (inst) out.push(inst); + } + return out; +} + async function reduceToolDurability( userId: string, guildId: string, @@ -445,7 +458,7 @@ export async function runMinigame( guildId, rewards ); - const mobsSpawned = await sampleMobs(mobs); + const mobsSpawned = await sampleMobInstances(mobs, level); // Reducir durabilidad de herramienta si se usó let toolInfo: RunResult["tool"] | undefined; @@ -478,8 +491,8 @@ export async function runMinigame( if (!hasWeapon) { // Registrar derrota simple contra la lista de mobs (no se derrotan mobs). - const mobLogs: CombatSummary["mobs"] = mobsSpawned.map((mk) => ({ - mobKey: mk, + const mobLogs: CombatSummary["mobs"] = mobsSpawned.map((m) => ({ + mobKey: m?.key ?? "unknown", maxHp: 0, defeated: false, totalDamageDealt: 0, @@ -596,16 +609,20 @@ export async function runMinigame( const factor = 0.8 + Math.random() * 0.4; // 0.8 - 1.2 return base * factor; }; - for (const mobKey of mobsSpawned) { + for (const mob of mobsSpawned) { if (currentHp <= 0) break; // jugador derrotado antes de iniciar este mob - // Stats simples del mob (placeholder mejorable con tabla real) - const mobBaseHp = 10 + Math.floor(Math.random() * 6); // 10-15 + // Stats simples del mob (usamos la instancia escalada) + const mobBaseHp = Math.max(1, Math.floor(mob?.scaled?.hp ?? 10)); let mobHp = mobBaseHp; const rounds: any[] = []; let round = 1; let mobDamageDealt = 0; // daño que jugador hace a este mob let mobDamageTakenFromMob = 0; // daño que jugador recibe de este mob - while (mobHp > 0 && currentHp > 0 && round <= 12) { + while ( + mobHp > 0 && + currentHp > 0 && + round <= (mob?.behavior?.maxRounds ?? 12) + ) { // Daño jugador -> mob const playerRaw = variance(eff.damage || 1) + 1; // asegurar >=1 const playerDamage = Math.max(1, Math.round(playerRaw)); @@ -627,7 +644,7 @@ export async function runMinigame( } } rounds.push({ - mobKey, + mobKey: mob?.key ?? "unknown", round, playerDamageDealt: playerDamage, playerDamageTaken: playerTaken, @@ -642,7 +659,7 @@ export async function runMinigame( round++; } mobLogs.push({ - mobKey, + mobKey: mob?.key ?? "unknown", maxHp: mobBaseHp, defeated: mobHp <= 0, totalDamageDealt: mobDamageDealt, @@ -830,7 +847,7 @@ export async function runMinigame( const resultJson: Prisma.InputJsonValue = { rewards: delivered, - mobs: mobsSpawned, + mobs: mobsSpawned.map((m) => m?.key ?? "unknown"), tool: toolInfo, weaponTool: weaponToolInfo, combat: combatSummary, @@ -879,7 +896,7 @@ export async function runMinigame( return { success: true, rewards: delivered, - mobs: mobsSpawned, + mobs: mobsSpawned.map((m) => m?.key ?? "unknown"), tool: toolInfo, weaponTool: weaponToolInfo, combat: combatSummary, diff --git a/src/game/mobs/README.md b/src/game/mobs/README.md new file mode 100644 index 0000000..ff599e9 --- /dev/null +++ b/src/game/mobs/README.md @@ -0,0 +1,16 @@ +# Mobs module + +## Propósito + +- Contener definiciones de mobs (plantillas) y helpers para obtener instancias escaladas por nivel. + +## Convenciones + +- `MOB_DEFINITIONS` contiene objetos `BaseMobDefinition` con configuración declarativa. +- Usar `getMobInstance(key, areaLevel)` para obtener una instancia lista para combate. +- Evitar lógica de combate en este archivo; este módulo solo expone datos y transformaciones determinísticas. + +## Futuro + +- Migrar `MOB_DEFINITIONS` a la base de datos o AppWrite y añadir cache si se requiere edición en runtime. +- Añadir validadores y tests para las definiciones. diff --git a/src/game/mobs/admin.ts b/src/game/mobs/admin.ts new file mode 100644 index 0000000..e03ec89 --- /dev/null +++ b/src/game/mobs/admin.ts @@ -0,0 +1,236 @@ +import { prisma } from "../../core/database/prisma"; +import { z } from "zod"; +import { BaseMobDefinition, MOB_DEFINITIONS, findMobDef } from "./mobData"; + +const BaseMobDefinitionSchema = z.object({ + key: z.string(), + name: z.string(), + tier: z.number().int().nonnegative(), + base: z.object({ + hp: z.number(), + attack: z.number(), + defense: z.number().optional(), + }), + scaling: z + .object({ + hpPerLevel: z.number().optional(), + attackPerLevel: z.number().optional(), + defensePerLevel: z.number().optional(), + hpMultiplierPerTier: z.number().optional(), + }) + .optional(), + tags: z.array(z.string()).optional(), + rewardMods: z + .object({ + coinMultiplier: z.number().optional(), + extraDropChance: z.number().optional(), + }) + .optional(), + behavior: z + .object({ + maxRounds: z.number().optional(), + aggressive: z.boolean().optional(), + critChance: z.number().optional(), + critMultiplier: z.number().optional(), + }) + .optional(), +}); + +type MobInput = z.infer; + +export type CreateOrUpdateResult = { + def: BaseMobDefinition; + row?: any; +}; + +function prismaMobAvailable(): boolean { + const anyPrisma: any = prisma as any; + if (!process.env.XATA_DB) return false; + return !!( + anyPrisma && + anyPrisma.mob && + typeof anyPrisma.mob.create === "function" + ); +} + +export async function listMobs(): Promise { + const rows = await listMobsWithRows(); + return rows.map((r) => r.def); +} + +export type MobWithRow = { + def: BaseMobDefinition; + id?: string | null; + guildId?: string | null; + isDb?: boolean; +}; + +export async function listMobsWithRows(): Promise { + const map: Record = {}; + // Start with built-ins + for (const d of MOB_DEFINITIONS) { + map[d.key] = { def: d, id: null, guildId: null, isDb: false }; + } + + if (!prismaMobAvailable()) { + return Object.values(map); + } + + try { + const anyPrisma: any = prisma as any; + const rows = await anyPrisma.mob.findMany(); + // eslint-disable-next-line no-console + console.info(`listMobsWithRows: DB returned ${rows.length} rows`); + for (const r of rows) { + const cfg = + r.metadata ?? + r.stats ?? + r.drops ?? + r.config ?? + r.definition ?? + r.data ?? + null; + if (!cfg || typeof cfg !== "object") continue; + try { + const parsed = BaseMobDefinitionSchema.parse(cfg as any); + map[parsed.key] = { + def: parsed, + id: r.id ?? null, + guildId: r.guildId ?? null, + isDb: true, + }; + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + "Skipping invalid mob row id=", + r.id, + (e as any)?.errors ?? e + ); + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn("listMobsWithRows: DB read failed:", (e as any)?.message ?? e); + } + + return Object.values(map).sort((a, b) => a.def.key.localeCompare(b.def.key)); +} + +export async function getMob(key: string): Promise { + // Check DB first + if (prismaMobAvailable()) { + try { + const anyPrisma: any = prisma as any; + const row = await anyPrisma.mob.findFirst({ where: { key } }); + if (row) { + const cfg = + row.metadata ?? + row.stats ?? + row.drops ?? + row.config ?? + row.definition ?? + row.data ?? + null; + if (cfg) { + try { + return BaseMobDefinitionSchema.parse(cfg as any); + } catch (e) { + return null; + } + } + } + } catch (e) { + // ignore DB issues + } + } + // Fallback to built-ins + return findMobDef(key); +} + +export async function createOrUpdateMob( + input: MobInput & { guildId?: string; category?: string } +): Promise { + const parsed = BaseMobDefinitionSchema.parse(input); + let row: any | undefined; + if (prismaMobAvailable()) { + try { + const anyPrisma: any = prisma as any; + const where: any = { key: parsed.key }; + if (input.guildId) where.guildId = input.guildId; + const existing = await anyPrisma.mob.findFirst({ where }); + if (existing) { + row = await anyPrisma.mob.update({ + where: { id: existing.id }, + data: { + name: parsed.name, + category: (input as any).category ?? null, + metadata: parsed, + }, + }); + // eslint-disable-next-line no-console + console.info( + `createOrUpdateMob: updated mob id=${row.id} key=${parsed.key}` + ); + } else { + row = await anyPrisma.mob.create({ + data: { + key: parsed.key, + name: parsed.name, + category: (input as any).category ?? null, + guildId: input.guildId ?? null, + metadata: parsed, + }, + }); + // eslint-disable-next-line no-console + console.info( + `createOrUpdateMob: created mob id=${row.id} key=${parsed.key}` + ); + } + } catch (e) { + // if DB fails, fallthrough to return parsed but do not throw + // eslint-disable-next-line no-console + console.warn( + "createOrUpdateMob: DB save failed:", + (e as any)?.message ?? e + ); + } + } + return { def: parsed, row }; +} + +export async function deleteMob(key: string): Promise { + if (prismaMobAvailable()) { + try { + const anyPrisma: any = prisma as any; + const existing = await anyPrisma.mob.findFirst({ where: { key } }); + if (existing) { + await anyPrisma.mob.delete({ where: { id: existing.id } }); + // eslint-disable-next-line no-console + console.info(`deleteMob: deleted mob id=${existing.id} key=${key}`); + return true; + } + } catch (e) { + // ignore + // eslint-disable-next-line no-console + console.warn("deleteMob: DB delete failed:", (e as any)?.message ?? e); + return false; + } + } + // If no DB or not found, attempt to delete from in-memory builtins (no-op) + return false; +} + +export async function ensureMobRepoUpToDate() { + // helper to tell mobData to refresh caches — import dynamically to avoid cycles + try { + const mod = await import("./mobData.js"); + if (typeof mod.refreshMobDefinitionsFromDb === "function") { + await mod.refreshMobDefinitionsFromDb(); + } + if (typeof mod.validateAllMobDefs === "function") { + mod.validateAllMobDefs(); + } + } catch (e) { + // ignore + } +} diff --git a/src/game/mobs/mobData.ts b/src/game/mobs/mobData.ts index 0a180bd..77cf1df 100644 --- a/src/game/mobs/mobData.ts +++ b/src/game/mobs/mobData.ts @@ -53,10 +53,6 @@ export const MOB_DEFINITIONS: BaseMobDefinition[] = [ }, ]; -export function findMobDef(key: string) { - return MOB_DEFINITIONS.find((m) => m.key === key) || null; -} - export function computeMobStats(def: BaseMobDefinition, areaLevel: number) { const lvl = Math.max(1, areaLevel); const s = def.scaling || {}; @@ -70,3 +66,206 @@ export function computeMobStats(def: BaseMobDefinition, areaLevel: number) { ).toFixed(2); return { hp, attack: atk, defense: defVal }; } + +/** + * MobInstance: representación de una entidad mob lista para usarse en combate. + * - incluye stats escaladas por nivel de área (hp, attack, defense) + * - preserva la definición base para referencias (name, tier, tags, behavior) + */ +export interface MobInstance { + key: string; + name: string; + tier: number; + base: BaseMobDefinition["base"]; + scaled: { hp: number; attack: number; defense: number }; + tags?: string[]; + rewardMods?: BaseMobDefinition["rewardMods"]; + behavior?: BaseMobDefinition["behavior"]; +} + +/** + * getMobInstance: devuelve una instancia de mob con stats calculadas. + * Si la definición no existe, devuelve null. + */ +export function getMobInstance( + key: string, + areaLevel: number +): MobInstance | null { + const def = findMobDef(key); + if (!def) return null; + const scaled = computeMobStats(def, areaLevel); + return { + key: def.key, + name: def.name, + tier: def.tier, + base: def.base, + scaled, + tags: def.tags, + rewardMods: def.rewardMods, + behavior: def.behavior, + }; +} + +export function listMobKeys(): string[] { + return MOB_DEFINITIONS.map((m) => m.key); +} + +// --- DB-backed optional loader + simple validation --- +import { prisma } from "../../core/database/prisma"; +import { z } from "zod"; + +const BaseMobDefinitionSchema = z.object({ + key: z.string(), + name: z.string(), + tier: z.number().int().nonnegative(), + base: z.object({ + hp: z.number(), + attack: z.number(), + defense: z.number().optional(), + }), + scaling: z + .object({ + hpPerLevel: z.number().optional(), + attackPerLevel: z.number().optional(), + defensePerLevel: z.number().optional(), + hpMultiplierPerTier: z.number().optional(), + }) + .optional(), + tags: z.array(z.string()).optional(), + rewardMods: z + .object({ + coinMultiplier: z.number().optional(), + extraDropChance: z.number().optional(), + }) + .optional(), + behavior: z + .object({ + maxRounds: z.number().optional(), + aggressive: z.boolean().optional(), + critChance: z.number().optional(), + critMultiplier: z.number().optional(), + }) + .optional(), +}); + +// Cache for DB-loaded definitions (key -> def) +const dbMobDefs: Record = {}; + +/** + * Try to refresh mob definitions from the database. This is optional and + * fails silently if the Prisma model/table doesn't exist or an error occurs. + * Call this during server startup to load editable mobs. + */ +export async function refreshMobDefinitionsFromDb() { + try { + // If no DB configured, skip + if (!process.env.XATA_DB) return; + const anyPrisma: any = prisma as any; + if (!anyPrisma.mob || typeof anyPrisma.mob.findMany !== "function") { + // Prisma model `mob` not present — skip quietly + return; + } + const rows = await anyPrisma.mob.findMany(); + // rows expected to contain a JSON/config column (we try `config` or `definition`) + const BaseMobDefinitionSchema = z.object({ + key: z.string(), + name: z.string(), + tier: z.number().int().nonnegative(), + base: z.object({ + hp: z.number(), + attack: z.number(), + defense: z.number().optional(), + }), + scaling: z + .object({ + hpPerLevel: z.number().optional(), + attackPerLevel: z.number().optional(), + defensePerLevel: z.number().optional(), + hpMultiplierPerTier: z.number().optional(), + }) + .optional(), + tags: z.array(z.string()).optional(), + rewardMods: z + .object({ + coinMultiplier: z.number().optional(), + extraDropChance: z.number().optional(), + }) + .optional(), + behavior: z + .object({ + maxRounds: z.number().optional(), + aggressive: z.boolean().optional(), + critChance: z.number().optional(), + critMultiplier: z.number().optional(), + }) + .optional(), + }); + + for (const r of rows) { + // Prisma model Mob stores arbitrary data in `metadata`, but some projects + // may place structured stats in `stats` or `drops`. Try those fields. + const cfg = + r.metadata ?? + r.stats ?? + r.drops ?? + r.config ?? + r.definition ?? + r.data ?? + null; + if (!cfg || typeof cfg !== "object") continue; + try { + const parsed = BaseMobDefinitionSchema.parse(cfg as any); + dbMobDefs[parsed.key] = parsed as BaseMobDefinition; + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + "Invalid mob definition in DB for row id=", + r.id, + (e as any)?.message ?? e + ); + } + } + } catch (err) { + // silently ignore DB issues — keep in-memory definitions as source of truth + // but log to console for debugging + // eslint-disable-next-line no-console + console.warn( + "refreshMobDefinitionsFromDb: could not load mobs from DB:", + (err && (err as Error).message) || err + ); + } +} + +/** + * Find mob definition checking DB-loaded defs first, then built-in definitions. + */ +export function findMobDef(key: string) { + if (dbMobDefs[key]) return dbMobDefs[key]; + return MOB_DEFINITIONS.find((m) => m.key === key) || null; +} + +export function validateAllMobDefs() { + const bad: string[] = []; + for (const m of MOB_DEFINITIONS) { + const r = BaseMobDefinitionSchema.safeParse(m); + if (!r.success) bad.push(m.key ?? ""); + } + for (const k of Object.keys(dbMobDefs)) { + const r = BaseMobDefinitionSchema.safeParse(dbMobDefs[k]); + if (!r.success) bad.push(k); + } + if (bad.length) { + // eslint-disable-next-line no-console + console.warn("validateAllMobDefs: invalid mob defs:", bad); + } + return bad.length === 0; +} + +/** + * Initialize mob repository: attempt to refresh from DB and validate definitions. + * Call this on server start (optional). + */ +export async function initializeMobRepository() { + await refreshMobDefinitionsFromDb(); + validateAllMobDefs(); +} diff --git a/src/game/mutations/service.ts b/src/game/mutations/service.ts index 1ea9dfc..edeb515 100644 --- a/src/game/mutations/service.ts +++ b/src/game/mutations/service.ts @@ -1,32 +1,38 @@ -import { prisma } from '../../core/database/prisma'; -import type { ItemProps } from '../economy/types'; -import { findItemByKey, getInventoryEntry } from '../economy/service'; - -function parseItemProps(json: unknown): ItemProps { - if (!json || typeof json !== 'object') return {}; - return json as ItemProps; -} +import { prisma } from "../../core/database/prisma"; +import type { ItemProps } from "../economy/types"; +import { findItemByKey, getInventoryEntry } from "../economy/service"; +import { parseItemProps } from "../core/utils"; export async function findMutationByKey(guildId: string, key: string) { return prisma.itemMutation.findFirst({ where: { key, OR: [{ guildId }, { guildId: null }] }, - orderBy: [{ guildId: 'desc' }], + orderBy: [{ guildId: "desc" }], }); } -export async function applyMutationToInventory(userId: string, guildId: string, itemKey: string, mutationKey: string) { - const { item, entry } = await getInventoryEntry(userId, guildId, itemKey, { createIfMissing: true }); - if (!entry) throw new Error('Inventario inexistente'); +export async function applyMutationToInventory( + userId: string, + guildId: string, + itemKey: string, + mutationKey: string +) { + const { item, entry } = await getInventoryEntry(userId, guildId, itemKey, { + createIfMissing: true, + }); + if (!entry) throw new Error("Inventario inexistente"); const props = parseItemProps(item.props); const policy = props.mutationPolicy; - if (policy?.deniedKeys?.includes(mutationKey)) throw new Error('Mutación denegada'); - if (policy?.allowedKeys && !policy.allowedKeys.includes(mutationKey)) throw new Error('Mutación no permitida'); + if (policy?.deniedKeys?.includes(mutationKey)) + throw new Error("Mutación denegada"); + if (policy?.allowedKeys && !policy.allowedKeys.includes(mutationKey)) + throw new Error("Mutación no permitida"); const mutation = await findMutationByKey(guildId, mutationKey); - if (!mutation) throw new Error('Mutación no encontrada'); + if (!mutation) throw new Error("Mutación no encontrada"); - await prisma.inventoryItemMutation.create({ data: { inventoryId: entry.id, mutationId: mutation.id } }); + await prisma.inventoryItemMutation.create({ + data: { inventoryId: entry.id, mutationId: mutation.id }, + }); return { ok: true } as const; } - diff --git a/src/main.ts b/src/main.ts index 30252b4..c1ef036 100644 --- a/src/main.ts +++ b/src/main.ts @@ -212,6 +212,15 @@ async function bootstrap() { logger.error({ err: e }, "Error cargando eventos"); } + // Inicializar repositorio de mobs (intenta cargar mobs desde DB si existe) + try { + // import dinamico para evitar ciclos en startup + const { initializeMobRepository } = await import("./game/mobs/mobData.js"); + await initializeMobRepository(); + } catch (e) { + logger.warn({ err: e }, "No se pudo inicializar el repositorio de mobs"); + } + // Registrar comandos en segundo plano con reintentos; no bloquea el arranque del bot withRetry("Registrar slash commands", async () => { await registeringCommands();