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

@@ -18,6 +18,7 @@
"tsc": "tsc", "tsc": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"seed:minigames": "npx tsx src/game/minigames/seed.ts", "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" "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": [], "keywords": [],
@@ -35,7 +36,8 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"newrelic": "13.4.0", "newrelic": "13.4.0",
"node-appwrite": "19.1.0", "node-appwrite": "19.1.0",
"pino": "9.13.0", "pino": "9.13.0",
"zod": "4.25.1",
"prisma": "6.16.2", "prisma": "6.16.2",
"redis": "5.8.2" "redis": "5.8.2"
}, },

41
scripts/mobAdminTest.ts Normal file
View File

@@ -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);
});

18
scripts/testMobData.ts Normal file
View File

@@ -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);
});

View File

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

View File

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

View File

@@ -271,6 +271,8 @@ export interface KeyPickerResult<T> {
entry: T | null; entry: T | null;
panelMessage: Message | null; panelMessage: Message | null;
reason: "selected" | "empty" | "cancelled" | "timeout"; 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>( export async function promptKeySelection<T>(
@@ -444,11 +446,12 @@ export async function promptKeySelection<T>(
const result = await new Promise<KeyPickerResult<T>>((resolve) => { const result = await new Promise<KeyPickerResult<T>>((resolve) => {
const finish = ( const finish = (
entry: T | null, entry: T | null,
reason: "selected" | "cancelled" | "timeout" reason: "selected" | "cancelled" | "timeout",
selectedValue?: string
) => { ) => {
if (resolved) return; if (resolved) return;
resolved = true; resolved = true;
resolve({ entry, panelMessage, reason }); resolve({ entry, panelMessage, reason, selectedValue });
}; };
const collector = panelMessage.createMessageComponentCollector({ 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"); collector.stop("selected");
return; return;
} }

View File

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

View File

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

View File

@@ -1,10 +1,20 @@
import { Message, MessageFlags, MessageComponentInteraction, ButtonInteraction, TextBasedChannel } from 'discord.js'; import {
import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10'; Message,
import type { CommandMessage } from '../../../core/types/commands'; MessageFlags,
import { hasManageGuildOrStaff } from '../../../core/lib/permissions'; MessageComponentInteraction,
import logger from '../../../core/lib/logger'; ButtonInteraction,
import type Amayo from '../../../core/client'; TextBasedChannel,
import { promptKeySelection } from './_helpers'; } 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 { interface MobEditorState {
key: string; key: string;
@@ -14,84 +24,103 @@ interface MobEditorState {
drops?: any; drops?: any;
} }
function createMobDisplay(state: MobEditorState, editing: boolean = false) { 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 || {}; const stats = state.stats || {};
return { return {
type: 17, type: 17,
accent_color: 0xFF0000, accent_color: 0xff0000,
components: [ components: [
{ type: 10, content: `# 👹 ${title}: \`${state.key}\`` }, { type: 10, content: `# 👹 ${title}: \`${state.key}\`` },
{ type: 14, divider: true }, { type: 14, divider: true },
{ {
type: 10, type: 10,
content: [ content: [
'**📋 Estado Actual:**', "**📋 Estado Actual:**",
`**Nombre:** ${state.name || '❌ No configurado'}`, `**Nombre:** ${state.name || "❌ No configurado"}`,
`**Categoría:** ${state.category || 'Sin categoría'}`, `**Categoría:** ${state.category || "Sin categoría"}`,
`**Attack:** ${stats.attack || 0}`, `**Attack:** ${stats.attack || 0}`,
`**HP:** ${stats.hp || 0}`, `**HP:** ${stats.hp || 0}`,
`**Defense:** ${stats.defense || 0}`, `**Defense:** ${stats.defense || 0}`,
`**Drops:** ${Object.keys(state.drops || {}).length} items`, `**Drops:** ${Object.keys(state.drops || {}).length} items`,
].join('\n'), ].join("\n"),
}, },
{ type: 14, divider: true }, { type: 14, divider: true },
{ {
type: 10, type: 10,
content: [ content: [
'**🎮 Instrucciones:**', "**🎮 Instrucciones:**",
'• **Base**: Nombre y categoría', "• **Base**: Nombre y categoría",
'• **Stats (JSON)**: Estadísticas del mob', "• **Stats (JSON)**: Estadísticas del mob",
'• **Drops (JSON)**: Items que dropea', "• **Drops (JSON)**: Items que dropea",
'• **Guardar**: Confirma los cambios', "• **Guardar**: Confirma los cambios",
'• **Cancelar**: Descarta los cambios', "• **Cancelar**: Descarta los cambios",
].join('\n'), ].join("\n"),
}, },
] ],
}; };
} }
export const command: CommandMessage = { export const command: CommandMessage = {
name: 'mob-editar', name: "mob-editar",
type: 'message', type: "message",
aliases: ['editar-mob','mobedit'], aliases: ["editar-mob", "mobedit"],
cooldown: 10, cooldown: 10,
description: 'Edita un Mob (enemigo) de este servidor con editor interactivo.', description:
category: 'Minijuegos', "Edita un Mob (enemigo) de este servidor con editor interactivo.",
usage: 'mob-editar', category: "Minijuegos",
usage: "mob-editar",
run: async (message: Message, _args: string[], client: Amayo) => { run: async (message: Message, _args: string[], client: Amayo) => {
const channel = message.channel as TextBasedChannel & { send: Function }; const channel = message.channel as TextBasedChannel & { send: Function };
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, client.prisma); const allowed = await hasManageGuildOrStaff(
message.member,
message.guild!.id,
client.prisma
);
if (!allowed) { if (!allowed) {
await (channel.send as any)({ await (channel.send as any)({
content: null, content: null,
flags: 32768, flags: 32768,
components: [{ components: [
type: 17, {
accent_color: 0xFF0000, type: 17,
components: [{ accent_color: 0xff0000,
type: 10, components: [
content: '❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.' {
}] type: 10,
}], content:
reply: { messageReference: message.id } "❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.",
},
],
},
],
reply: { messageReference: message.id },
}); });
return; return;
} }
const guildId = message.guild!.id; 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, { const selection = await promptKeySelection(message, {
entries: mobs, entries: localEntries,
customIdPrefix: 'mob_edit', customIdPrefix: "mob_edit",
title: 'Selecciona un mob para editar', title: "Selecciona un mob para editar",
emptyText: '⚠️ **No hay mobs configurados.** Usa `!mob-crear` primero.', emptyText: "⚠️ **No hay mobs configurados.** Usa `!mob-crear` primero.",
placeholder: 'Elige un mob…', placeholder: "Elige un mob…",
filterHint: 'Filtra por nombre, key o categoría.', filterHint: "Filtra por nombre, key o categoría.",
getOption: (mob) => ({ getOption: (entry: any) => ({
value: mob.id, value: entry.id ?? entry.def.key,
label: mob.name ?? mob.key, label: entry.def.name ?? entry.def.key,
description: [mob.category ?? 'Sin categoría', mob.key].filter(Boolean).join(' • '), description: [entry.def?.category ?? "Sin categoría", entry.def.key]
keywords: [mob.key, mob.name ?? '', mob.category ?? ''], .filter(Boolean)
.join(" • "),
keywords: [
entry.def.key,
entry.def.name ?? "",
entry.def?.category ?? "",
],
}), }),
}); });
@@ -99,14 +128,23 @@ export const command: CommandMessage = {
return; 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 = { const state: MobEditorState = {
key: mob.key, key: entry.def.key,
name: mob.name, name: (dbRow && dbRow.name) ?? entry.def.name,
category: mob.category ?? undefined, category: (dbRow && dbRow.category) ?? entry.def?.category ?? undefined,
stats: mob.stats ?? {}, stats: (dbRow && dbRow.stats) ?? entry.def?.base ?? {},
drops: mob.drops ?? {}, drops: (dbRow && dbRow.drops) ?? entry.def?.drops ?? {},
}; };
const buildEditorComponents = () => [ const buildEditorComponents = () => [
@@ -114,13 +152,38 @@ export const command: CommandMessage = {
{ {
type: 1, type: 1,
components: [ 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,
{ type: 2, style: ButtonStyle.Secondary, label: 'Drops (JSON)', custom_id: 'mb_drops' }, style: ButtonStyle.Primary,
{ type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'mb_save' }, label: "Base",
{ type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'mb_cancel' }, 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; const editorMsg = selection.panelMessage;
@@ -130,77 +193,142 @@ export const command: CommandMessage = {
components: buildEditorComponents(), components: buildEditorComponents(),
}); });
const collector = editorMsg.createMessageComponentCollector({ time: 30 * 60_000, filter: (i) => i.user.id === message.author.id }); const collector = editorMsg.createMessageComponentCollector({
collector.on('collect', async (i: MessageComponentInteraction) => { time: 30 * 60_000,
filter: (i) => i.user.id === message.author.id,
});
collector.on("collect", async (i: MessageComponentInteraction) => {
try { try {
if (!i.isButton()) return; if (!i.isButton()) return;
switch (i.customId) { switch (i.customId) {
case 'mb_cancel': case "mb_cancel":
await i.deferUpdate(); await i.deferUpdate();
await editorMsg.edit({ await editorMsg.edit({
content: null, content: null,
flags: 32768, flags: 32768,
components: [{ components: [
type: 17, {
accent_color: 0xFF0000, type: 17,
components: [{ accent_color: 0xff0000,
type: 10, components: [
content: '**❌ Editor cancelado.**' {
}] type: 10,
}] content: "**❌ Editor cancelado.**",
},
],
},
],
}); });
collector.stop('cancel'); collector.stop("cancel");
return; return;
case 'mb_base': case "mb_base":
await showBaseModal(i as ButtonInteraction, state, editorMsg, buildEditorComponents); await showBaseModal(
i as ButtonInteraction,
state,
editorMsg,
buildEditorComponents
);
return; return;
case 'mb_stats': case "mb_stats":
await showJsonModal(i as ButtonInteraction, state, 'stats', 'Stats del Mob (JSON)', editorMsg, buildEditorComponents); await showJsonModal(
i as ButtonInteraction,
state,
"stats",
"Stats del Mob (JSON)",
editorMsg,
buildEditorComponents
);
return; return;
case 'mb_drops': case "mb_drops":
await showJsonModal(i as ButtonInteraction, state, 'drops', 'Drops del Mob (JSON)', editorMsg, buildEditorComponents); await showJsonModal(
i as ButtonInteraction,
state,
"drops",
"Drops del Mob (JSON)",
editorMsg,
buildEditorComponents
);
return; return;
case 'mb_save': case "mb_save":
if (!state.name) { 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; return;
} }
await client.prisma.mob.update({ where: { id: mob.id }, data: { name: state.name!, category: state.category ?? null, stats: state.stats ?? {}, drops: state.drops ?? {} } }); try {
await i.reply({ content: '✅ Mob actualizado!', flags: MessageFlags.Ephemeral }); 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({ await editorMsg.edit({
content: null, content: null,
flags: 32768, flags: 32768,
components: [{ components: [
type: 17, {
accent_color: 0x00FF00, type: 17,
components: [{ accent_color: 0x00ff00,
type: 10, components: [
content: `**✅ Mob \`${state.key}\` actualizado exitosamente.**` {
}] type: 10,
}] content: `**✅ Mob \`${state.key}\` actualizado exitosamente.**`,
},
],
},
],
}); });
collector.stop('saved'); collector.stop("saved");
return; return;
} }
} catch (err) { } catch (err) {
logger.error({ err }, 'mob-editar'); logger.error({ err }, "mob-editar");
if (!i.deferred && !i.replied) await i.reply({ content: '❌ Error procesando la acción.', flags: MessageFlags.Ephemeral }); if (!i.deferred && !i.replied)
await i.reply({
content: "❌ Error procesando la acción.",
flags: MessageFlags.Ephemeral,
});
} }
}); });
collector.on('end', async (_c, reason) => { collector.on("end", async (_c, reason) => {
if (reason === 'time') { if (reason === "time") {
try { try {
await editorMsg.edit({ await editorMsg.edit({
content: null, content: null,
flags: 32768, flags: 32768,
components: [{ components: [
type: 17, {
accent_color: 0xFFA500, type: 17,
components: [{ accent_color: 0xffa500,
type: 10, components: [
content: '**⏰ Editor expirado.**' {
}] type: 10,
}] content: "**⏰ Editor expirado.**",
},
],
},
],
}); });
} catch {} } catch {}
} }
@@ -208,41 +336,94 @@ export const command: CommandMessage = {
}, },
}; };
async function showBaseModal(i: ButtonInteraction, state: MobEditorState, editorMsg: Message, buildComponents: () => any[]) { async function showBaseModal(
const modal = { title: 'Base del Mob', customId: 'mb_base_modal', components: [ i: ButtonInteraction,
{ type: ComponentType.Label, label: 'Nombre', component: { type: ComponentType.TextInput, customId: 'name', style: TextInputStyle.Short, required: true, value: state.name ?? '' } }, state: MobEditorState,
{ type: ComponentType.Label, label: 'Categoría (opcional)', component: { type: ComponentType.TextInput, customId: 'category', style: TextInputStyle.Short, required: false, value: state.category ?? '' } }, editorMsg: Message,
] } as const; 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); await i.showModal(modal);
try { try {
const sub = await i.awaitModalSubmit({ time: 300_000 }); const sub = await i.awaitModalSubmit({ time: 300_000 });
state.name = sub.components.getTextInputValue('name').trim(); state.name = sub.components.getTextInputValue("name").trim();
const cat = sub.components.getTextInputValue('category')?.trim(); const cat = sub.components.getTextInputValue("category")?.trim();
state.category = cat || undefined; state.category = cat || undefined;
await sub.deferUpdate(); await sub.deferUpdate();
await editorMsg.edit({ await editorMsg.edit({
content: null, content: null,
flags: 32768, flags: 32768,
components: buildComponents() components: buildComponents(),
}); });
} catch {} } 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 current = JSON.stringify(state[field] ?? {});
const modal = { title, customId: `mb_json_${field}`, components: [ const modal = {
{ type: ComponentType.Label, label: 'JSON', component: { type: ComponentType.TextInput, customId: 'json', style: TextInputStyle.Paragraph, required: false, value: current.slice(0,4000) } }, title,
] } as const; 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); await i.showModal(modal);
try { try {
const sub = await i.awaitModalSubmit({ time: 300_000 }); const sub = await i.awaitModalSubmit({ time: 300_000 });
const raw = sub.components.getTextInputValue('json'); const raw = sub.components.getTextInputValue("json");
if (raw) { if (raw) {
try { try {
state[field] = JSON.parse(raw); state[field] = JSON.parse(raw);
await sub.deferUpdate(); await sub.deferUpdate();
} catch { } catch {
await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral }); await sub.reply({
content: "❌ JSON inválido.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }
} else { } else {
@@ -252,7 +433,7 @@ async function showJsonModal(i: ButtonInteraction, state: MobEditorState, field:
await editorMsg.edit({ await editorMsg.edit({
content: null, content: null,
flags: 32768, flags: 32768,
components: buildComponents() components: buildComponents(),
}); });
} catch {} } catch {}
} }

View File

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

View File

@@ -5,11 +5,7 @@ import {
} from "./statusEffectsService"; } from "./statusEffectsService";
import type { ItemProps } from "../economy/types"; import type { ItemProps } from "../economy/types";
import { ensureUserAndGuildExist } from "../core/userService"; import { ensureUserAndGuildExist } from "../core/userService";
import { parseItemProps } from "../core/utils";
function parseItemProps(json: unknown): ItemProps {
if (!json || typeof json !== "object") return {};
return json as ItemProps;
}
export async function ensurePlayerState(userId: string, guildId: string) { export async function ensurePlayerState(userId: string, guildId: string) {
// Asegurar que User y Guild existan antes de crear/buscar state // Asegurar que User y Guild existan antes de crear/buscar state

View File

@@ -1,34 +1,32 @@
import { prisma } from '../../core/database/prisma'; import { prisma } from "../../core/database/prisma";
import { assertNotOnCooldown, setCooldown } from '../cooldowns/service'; import { assertNotOnCooldown, setCooldown } from "../cooldowns/service";
import { findItemByKey, consumeItemByKey } from '../economy/service'; import { findItemByKey, consumeItemByKey } from "../economy/service";
import type { ItemProps } from '../economy/types'; import type { ItemProps } from "../economy/types";
import { getEffectiveStats, adjustHP } from '../combat/equipmentService'; import { getEffectiveStats, adjustHP } from "../combat/equipmentService";
import { parseItemProps } from "../core/utils";
import { getCooldownKeyForFood, calculateHealingFromFood } from "./utils";
function parseItemProps(json: unknown): ItemProps { export async function useConsumableByKey(
if (!json || typeof json !== 'object') return {}; userId: string,
return json as ItemProps; guildId: string,
} itemKey: string
) {
export async function useConsumableByKey(userId: string, guildId: string, itemKey: string) {
const item = await findItemByKey(guildId, itemKey); 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 props = parseItemProps(item.props);
const food = props.food; 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); await assertNotOnCooldown(userId, guildId, cdKey);
// Calcular sanación // Calcular sanación
const stats = await getEffectiveStats(userId, guildId); const stats = await getEffectiveStats(userId, guildId);
const flat = Math.max(0, food.healHp ?? 0); const heal = calculateHealingFromFood(food, stats.maxHp);
const perc = Math.max(0, food.healPercent ?? 0);
const byPerc = Math.floor((perc / 100) * stats.maxHp);
const heal = Math.max(1, flat + byPerc);
// Consumir el ítem // Consumir el ítem
const { consumed } = await consumeItemByKey(userId, guildId, item.key, 1); 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 // Aplicar curación
await adjustHP(userId, guildId, heal); await adjustHP(userId, guildId, heal);
@@ -40,4 +38,3 @@ export async function useConsumableByKey(userId: string, guildId: string, itemKe
return { healed: heal } as const; return { healed: heal } as const;
} }

View File

@@ -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);
}

55
src/game/core/utils.ts Normal file
View File

@@ -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<T>(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,
};

View File

@@ -7,17 +7,17 @@ import type {
} from "./types"; } from "./types";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { ensureUserAndGuildExist } from "../core/userService"; import { ensureUserAndGuildExist } from "../core/userService";
import coreUtils from "../core/utils";
// Utilidades de tiempo // Reusar utilidades centrales desde game core
function now(): Date { const {
return new Date(); now,
} isWithin,
ensureArray,
function isWithin(date: Date, from?: Date | null, to?: Date | null): boolean { parseItemProps,
if (from && date < from) return false; parseState,
if (to && date > to) return false; updateInventoryEntryState,
return true; } = coreUtils as any;
}
// Resuelve un EconomyItem por key con alcance de guild o global // Resuelve un EconomyItem por key con alcance de guild o global
export async function findItemByKey(guildId: string, key: string) { export async function findItemByKey(guildId: string, key: string) {
@@ -89,15 +89,8 @@ export async function getInventoryEntry(
return { item, entry } as const; return { item, entry } as const;
} }
function parseItemProps(json: unknown): ItemProps { // utilidades parseItemProps, parseState, ensureArray y updateInventoryEntryState
if (!json || typeof json !== "object") return {}; // provistas por coreUtils importado arriba
return json as ItemProps;
}
function parseState(json: unknown): InventoryState {
if (!json || typeof json !== "object") return {};
return json as InventoryState;
}
function checkUsableWindow(item: { function checkUsableWindow(item: {
usableFrom: Date | null; usableFrom: Date | null;
@@ -156,7 +149,7 @@ export async function addItemByKey(
} else { } else {
// No apilable: usar state.instances // No apilable: usar state.instances
const state = parseState(entry.state); const state = parseState(entry.state);
state.instances ??= []; state.instances = ensureArray(state.instances);
const canAdd = Math.max( const canAdd = Math.max(
0, 0,
Math.min(qty, Math.max(0, max - state.instances.length)) Math.min(qty, Math.max(0, max - state.instances.length))
@@ -173,13 +166,12 @@ export async function addItemByKey(
state.instances.push({}); state.instances.push({});
} }
} }
const updated = await prisma.inventoryEntry.update({ const updated = await updateInventoryEntryState(
where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, userId,
data: { guildId,
state: state as unknown as Prisma.InputJsonValue, item.id,
quantity: state.instances.length, state
}, );
});
return { added: canAdd, entry: updated } as const; return { added: canAdd, entry: updated } as const;
} }
} }
@@ -203,18 +195,16 @@ export async function consumeItemByKey(
return { consumed, entry: updated } as const; return { consumed, entry: updated } as const;
} else { } else {
const state = parseState(entry.state); const state = parseState(entry.state);
const instances = state.instances ?? []; state.instances = ensureArray(state.instances);
const consumed = Math.min(qty, instances.length); const consumed = Math.min(qty, state.instances.length);
if (consumed === 0) return { consumed: 0 } as const; if (consumed === 0) return { consumed: 0 } as const;
instances.splice(0, consumed); state.instances.splice(0, consumed);
const newState: InventoryState = { ...state, instances }; const updated = await updateInventoryEntryState(
const updated = await prisma.inventoryEntry.update({ userId,
where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, guildId,
data: { item.id,
state: newState as unknown as Prisma.InputJsonValue, state
quantity: instances.length, );
},
});
return { consumed, entry: updated } as const; return { consumed, entry: updated } as const;
} }
} }
@@ -232,7 +222,7 @@ export async function openChestByKey(
const props = parseItemProps(item.props); const props = parseItemProps(item.props);
const chest = props.chest ?? {}; const chest = props.chest ?? {};
if (!chest.enabled) throw new Error("Este ítem no se puede abrir"); 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 mode = chest.randomMode || "all";
const result: OpenChestResult = { const result: OpenChestResult = {
coinsDelta: 0, coinsDelta: 0,

View File

@@ -19,6 +19,7 @@ import {
import { logToolBreak } from "../lib/toolBreakLog"; import { logToolBreak } from "../lib/toolBreakLog";
import { updateStats } from "../stats/service"; // 🟩 local authoritative import { updateStats } from "../stats/service"; // 🟩 local authoritative
import type { ItemProps, InventoryState } from "../economy/types"; import type { ItemProps, InventoryState } from "../economy/types";
import { parseItemProps, parseState as parseInvState } from "../core/utils";
import type { import type {
LevelRequirements, LevelRequirements,
RunMinigameOptions, RunMinigameOptions,
@@ -120,15 +121,7 @@ async function ensureAreaAndLevel(
return { area, lvl } as const; return { area, lvl } as const;
} }
function parseItemProps(json: unknown): ItemProps { // parseItemProps y parseInvState son importados desde ../core/utils para centralizar parsing
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;
}
async function validateRequirements( async function validateRequirements(
userId: string, userId: string,
@@ -280,6 +273,26 @@ async function sampleMobs(mobs?: MobsTable): Promise<string[]> {
return out; 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<ReturnType<typeof getMobInstance>[]> {
const out: ReturnType<typeof getMobInstance>[] = [];
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( async function reduceToolDurability(
userId: string, userId: string,
guildId: string, guildId: string,
@@ -445,7 +458,7 @@ export async function runMinigame(
guildId, guildId,
rewards rewards
); );
const mobsSpawned = await sampleMobs(mobs); const mobsSpawned = await sampleMobInstances(mobs, level);
// Reducir durabilidad de herramienta si se usó // Reducir durabilidad de herramienta si se usó
let toolInfo: RunResult["tool"] | undefined; let toolInfo: RunResult["tool"] | undefined;
@@ -478,8 +491,8 @@ export async function runMinigame(
if (!hasWeapon) { if (!hasWeapon) {
// Registrar derrota simple contra la lista de mobs (no se derrotan mobs). // Registrar derrota simple contra la lista de mobs (no se derrotan mobs).
const mobLogs: CombatSummary["mobs"] = mobsSpawned.map((mk) => ({ const mobLogs: CombatSummary["mobs"] = mobsSpawned.map((m) => ({
mobKey: mk, mobKey: m?.key ?? "unknown",
maxHp: 0, maxHp: 0,
defeated: false, defeated: false,
totalDamageDealt: 0, totalDamageDealt: 0,
@@ -596,16 +609,20 @@ export async function runMinigame(
const factor = 0.8 + Math.random() * 0.4; // 0.8 - 1.2 const factor = 0.8 + Math.random() * 0.4; // 0.8 - 1.2
return base * factor; return base * factor;
}; };
for (const mobKey of mobsSpawned) { for (const mob of mobsSpawned) {
if (currentHp <= 0) break; // jugador derrotado antes de iniciar este mob if (currentHp <= 0) break; // jugador derrotado antes de iniciar este mob
// Stats simples del mob (placeholder mejorable con tabla real) // Stats simples del mob (usamos la instancia escalada)
const mobBaseHp = 10 + Math.floor(Math.random() * 6); // 10-15 const mobBaseHp = Math.max(1, Math.floor(mob?.scaled?.hp ?? 10));
let mobHp = mobBaseHp; let mobHp = mobBaseHp;
const rounds: any[] = []; const rounds: any[] = [];
let round = 1; let round = 1;
let mobDamageDealt = 0; // daño que jugador hace a este mob let mobDamageDealt = 0; // daño que jugador hace a este mob
let mobDamageTakenFromMob = 0; // daño que jugador recibe de 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 // Daño jugador -> mob
const playerRaw = variance(eff.damage || 1) + 1; // asegurar >=1 const playerRaw = variance(eff.damage || 1) + 1; // asegurar >=1
const playerDamage = Math.max(1, Math.round(playerRaw)); const playerDamage = Math.max(1, Math.round(playerRaw));
@@ -627,7 +644,7 @@ export async function runMinigame(
} }
} }
rounds.push({ rounds.push({
mobKey, mobKey: mob?.key ?? "unknown",
round, round,
playerDamageDealt: playerDamage, playerDamageDealt: playerDamage,
playerDamageTaken: playerTaken, playerDamageTaken: playerTaken,
@@ -642,7 +659,7 @@ export async function runMinigame(
round++; round++;
} }
mobLogs.push({ mobLogs.push({
mobKey, mobKey: mob?.key ?? "unknown",
maxHp: mobBaseHp, maxHp: mobBaseHp,
defeated: mobHp <= 0, defeated: mobHp <= 0,
totalDamageDealt: mobDamageDealt, totalDamageDealt: mobDamageDealt,
@@ -830,7 +847,7 @@ export async function runMinigame(
const resultJson: Prisma.InputJsonValue = { const resultJson: Prisma.InputJsonValue = {
rewards: delivered, rewards: delivered,
mobs: mobsSpawned, mobs: mobsSpawned.map((m) => m?.key ?? "unknown"),
tool: toolInfo, tool: toolInfo,
weaponTool: weaponToolInfo, weaponTool: weaponToolInfo,
combat: combatSummary, combat: combatSummary,
@@ -879,7 +896,7 @@ export async function runMinigame(
return { return {
success: true, success: true,
rewards: delivered, rewards: delivered,
mobs: mobsSpawned, mobs: mobsSpawned.map((m) => m?.key ?? "unknown"),
tool: toolInfo, tool: toolInfo,
weaponTool: weaponToolInfo, weaponTool: weaponToolInfo,
combat: combatSummary, combat: combatSummary,

16
src/game/mobs/README.md Normal file
View File

@@ -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.

236
src/game/mobs/admin.ts Normal file
View File

@@ -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<typeof BaseMobDefinitionSchema>;
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<BaseMobDefinition[]> {
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<MobWithRow[]> {
const map: Record<string, MobWithRow> = {};
// 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<BaseMobDefinition | null> {
// 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<CreateOrUpdateResult> {
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<boolean> {
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
}
}

View File

@@ -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) { export function computeMobStats(def: BaseMobDefinition, areaLevel: number) {
const lvl = Math.max(1, areaLevel); const lvl = Math.max(1, areaLevel);
const s = def.scaling || {}; const s = def.scaling || {};
@@ -70,3 +66,206 @@ export function computeMobStats(def: BaseMobDefinition, areaLevel: number) {
).toFixed(2); ).toFixed(2);
return { hp, attack: atk, defense: defVal }; 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<string, BaseMobDefinition> = {};
/**
* 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 ?? "<unknown>");
}
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();
}

View File

@@ -1,32 +1,38 @@
import { prisma } from '../../core/database/prisma'; import { prisma } from "../../core/database/prisma";
import type { ItemProps } from '../economy/types'; import type { ItemProps } from "../economy/types";
import { findItemByKey, getInventoryEntry } from '../economy/service'; import { findItemByKey, getInventoryEntry } from "../economy/service";
import { parseItemProps } from "../core/utils";
function parseItemProps(json: unknown): ItemProps {
if (!json || typeof json !== 'object') return {};
return json as ItemProps;
}
export async function findMutationByKey(guildId: string, key: string) { export async function findMutationByKey(guildId: string, key: string) {
return prisma.itemMutation.findFirst({ return prisma.itemMutation.findFirst({
where: { key, OR: [{ guildId }, { guildId: null }] }, where: { key, OR: [{ guildId }, { guildId: null }] },
orderBy: [{ guildId: 'desc' }], orderBy: [{ guildId: "desc" }],
}); });
} }
export async function applyMutationToInventory(userId: string, guildId: string, itemKey: string, mutationKey: string) { export async function applyMutationToInventory(
const { item, entry } = await getInventoryEntry(userId, guildId, itemKey, { createIfMissing: true }); userId: string,
if (!entry) throw new Error('Inventario inexistente'); 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 props = parseItemProps(item.props);
const policy = props.mutationPolicy; const policy = props.mutationPolicy;
if (policy?.deniedKeys?.includes(mutationKey)) throw new Error('Mutación denegada'); if (policy?.deniedKeys?.includes(mutationKey))
if (policy?.allowedKeys && !policy.allowedKeys.includes(mutationKey)) throw new Error('Mutación no permitida'); 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); 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; return { ok: true } as const;
} }

View File

@@ -212,6 +212,15 @@ async function bootstrap() {
logger.error({ err: e }, "Error cargando eventos"); 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 // Registrar comandos en segundo plano con reintentos; no bloquea el arranque del bot
withRetry("Registrar slash commands", async () => { withRetry("Registrar slash commands", async () => {
await registeringCommands(); await registeringCommands();