feat: Add scripts for mob dependency management and server setup

- Implemented `findMobDependencies.ts` to identify foreign key constraints referencing the Mob table and log dependent rows.
- Created `fullServerSetup.ts` for idempotent server setup, including economy items, item recipes, game areas, mobs, and optional demo mob attacks.
- Developed `removeInvalidMobsWithDeps.ts` to delete invalid mobs and their dependencies, backing up affected scheduled mob attacks.
- Added unit tests in `testMobUnit.ts` and `mob.test.ts` for mob functionality, including stats computation and instance retrieval.
- Introduced reward modification tests in `testRewardMods.ts` and `rewardMods.unit.ts` to validate drop selection and coin multiplier behavior.
- Enhanced command handling for mob deletion in `mobDelete.ts` and setup examples in `setup.ts`, ensuring proper permissions and feedback.
- Created utility functions in `testHelpers.ts` for deterministic drop selection from mob definitions.
This commit is contained in:
Shni
2025-10-14 14:58:38 -05:00
parent f36fa24e46
commit 852b1d02a2
24 changed files with 2158 additions and 177 deletions

View File

@@ -1,6 +1,5 @@
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";

View File

@@ -1,9 +1,19 @@
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 { Message, MessageComponentInteraction, MessageFlags, ButtonInteraction, TextBasedChannel } from 'discord.js';
import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10';
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 {
Message,
MessageComponentInteraction,
MessageFlags,
ButtonInteraction,
TextBasedChannel,
} from "discord.js";
import {
ComponentType,
TextInputStyle,
ButtonStyle,
} from "discord-api-types/v10";
interface AreaState {
key: string;
@@ -14,43 +24,43 @@ interface AreaState {
}
function buildAreaDisplay(state: AreaState, editing: boolean = false) {
const title = editing ? 'Editando Área' : 'Creando Área';
const title = editing ? "Editando Área" : "Creando Área";
const statusText = [
'**📋 Estado Actual:**',
`**Nombre:** ${state.name || '❌ No configurado'}`,
`**Tipo:** ${state.type || '❌ No configurado'}`,
"**📋 Estado Actual:**",
`**Nombre:** ${state.name || "❌ No configurado"}`,
`**Tipo:** ${state.type || "❌ No configurado"}`,
`**Config:** ${Object.keys(state.config || {}).length} campos`,
`**Metadata:** ${Object.keys(state.metadata || {}).length} campos`
].join('\n');
`**Metadata:** ${Object.keys(state.metadata || {}).length} campos`,
].join("\n");
const instructionsText = [
'**🎮 Instrucciones:**',
'• **Base**: Configura nombre y tipo',
'• **Config (JSON)**: Configuración técnica',
'• **Meta (JSON)**: Metadatos adicionales',
'• **Guardar**: Confirma los cambios',
'• **Cancelar**: Descarta los cambios'
].join('\n');
"**🎮 Instrucciones:**",
"• **Base**: Configura nombre y tipo",
"• **Config (JSON)**: Configuración técnica",
"• **Meta (JSON)**: Metadatos adicionales",
"• **Guardar**: Confirma los cambios",
"• **Cancelar**: Descarta los cambios",
].join("\n");
return {
type: 17,
accent_color: 0x00FF00,
accent_color: 0x00ff00,
components: [
{
type: 10,
content: `# 🗺️ ${title}: \`${state.key}\``
content: `# 🗺️ ${title}: \`${state.key}\``,
},
{ type: 14, divider: true },
{
type: 10,
content: statusText
content: statusText,
},
{ type: 14, divider: true },
{
type: 10,
content: instructionsText
}
]
content: instructionsText,
},
],
};
}
@@ -59,38 +69,73 @@ const buildEditorComponents = (state: AreaState, editing: boolean = false) => [
{
type: 1,
components: [
{ type: 2, style: ButtonStyle.Primary, label: 'Base', custom_id: 'ga_base' },
{ type: 2, style: ButtonStyle.Secondary, label: 'Config (JSON)', custom_id: 'ga_config' },
{ type: 2, style: ButtonStyle.Secondary, label: 'Meta (JSON)', custom_id: 'ga_meta' },
{ type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'ga_save' },
{ type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'ga_cancel' },
]
}
{
type: 2,
style: ButtonStyle.Primary,
label: "Base",
custom_id: "ga_base",
},
{
type: 2,
style: ButtonStyle.Secondary,
label: "Config (JSON)",
custom_id: "ga_config",
},
{
type: 2,
style: ButtonStyle.Secondary,
label: "Meta (JSON)",
custom_id: "ga_meta",
},
{
type: 2,
style: ButtonStyle.Success,
label: "Guardar",
custom_id: "ga_save",
},
{
type: 2,
style: ButtonStyle.Danger,
label: "Cancelar",
custom_id: "ga_cancel",
},
],
},
];
export const command: CommandMessage = {
name: 'area-crear',
type: 'message',
aliases: ['crear-area','areacreate'],
name: "area-crear",
type: "message",
aliases: ["crear-area", "areacreate"],
cooldown: 10,
description: 'Crea una GameArea (mina/laguna/arena/farm) para este servidor con editor.',
usage: 'area-crear <key-única>',
description:
"Crea una GameArea (mina/laguna/arena/farm) para este servidor con editor.",
usage: "area-crear <key-única>",
run: async (message, args, _client: Amayo) => {
const channel = message.channel as TextBasedChannel & { send: Function };
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, prisma);
const allowed = await hasManageGuildOrStaff(
message.member,
message.guild!.id,
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;
}
@@ -100,15 +145,20 @@ export const command: CommandMessage = {
await (channel.send as any)({
content: null,
flags: 32768,
components: [{
type: 17,
accent_color: 0xFFA500,
components: [{
type: 10,
content: '⚠️ **Uso Incorrecto**\n└ Uso: `!area-crear <key-única>`'
}]
}],
reply: { messageReference: message.id }
components: [
{
type: 17,
accent_color: 0xffa500,
components: [
{
type: 10,
content:
"⚠️ **Uso Incorrecto**\n└ Uso: `!area-crear <key-única>`",
},
],
},
],
reply: { messageReference: message.id },
});
return;
}
@@ -119,15 +169,20 @@ export const command: CommandMessage = {
await (channel.send as any)({
content: null,
flags: 32768,
components: [{
type: 17,
accent_color: 0xFF0000,
components: [{
type: 10,
content: '❌ **Área Ya Existe**\n└ Ya existe un área con esa key en este servidor.'
}]
}],
reply: { messageReference: message.id }
components: [
{
type: 17,
accent_color: 0xff0000,
components: [
{
type: 10,
content:
"❌ **Área Ya Existe**\n└ Ya existe un área con esa key en este servidor.",
},
],
},
],
reply: { messageReference: message.id },
});
return;
}
@@ -138,133 +193,271 @@ export const command: CommandMessage = {
content: null,
flags: 32768,
components: buildEditorComponents(state, false),
reply: { messageReference: message.id }
reply: { messageReference: message.id },
});
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 'ga_cancel':
case "ga_cancel":
await i.deferUpdate();
await editorMsg.edit({
content: null,
flags: 32768,
components: [{
type: 17,
accent_color: 0xFF0000,
components: [{
type: 10,
content: '**❌ Editor de Área cancelado.**'
}]
}]
components: [
{
type: 17,
accent_color: 0xff0000,
components: [
{
type: 10,
content: "**❌ Editor de Área cancelado.**",
},
],
},
],
});
collector.stop('cancel');
collector.stop("cancel");
return;
case 'ga_base':
await showBaseModal(i as ButtonInteraction, state, editorMsg, false);
case "ga_base":
await showBaseModal(
i as ButtonInteraction,
state,
editorMsg,
false
);
return;
case 'ga_config':
await showJsonModal(i as ButtonInteraction, state, 'config', 'Config del Área', editorMsg, false);
case "ga_config":
await showJsonModal(
i as ButtonInteraction,
state,
"config",
"Config del Área",
editorMsg,
false
);
return;
case 'ga_meta':
await showJsonModal(i as ButtonInteraction, state, 'metadata', 'Meta del Área', editorMsg, false);
case "ga_meta":
await showJsonModal(
i as ButtonInteraction,
state,
"metadata",
"Meta del Área",
editorMsg,
false
);
return;
case 'ga_save':
if (!state.name || !state.type) { await i.reply({ content: '❌ Completa Base (nombre/tipo).', flags: MessageFlags.Ephemeral }); return; }
await prisma.gameArea.create({ data: { guildId, key: state.key, name: state.name!, type: state.type!, config: state.config ?? {}, metadata: state.metadata ?? {} } });
await i.reply({ content: '✅ Área guardada.', flags: MessageFlags.Ephemeral });
case "ga_save":
if (!state.name || !state.type) {
await i.reply({
content: "❌ Completa Base (nombre/tipo).",
flags: MessageFlags.Ephemeral,
});
return;
}
await prisma.gameArea.create({
data: {
guildId,
key: state.key,
name: state.name!,
type: state.type!,
config: state.config ?? {},
metadata: state.metadata ?? {},
},
});
await i.reply({
content: "✅ Área guardada.",
flags: MessageFlags.Ephemeral,
});
await editorMsg.edit({
content: null,
flags: 32768,
components: [{
type: 17,
accent_color: 0x00FF00,
components: [{
type: 10,
content: `**✅ Área \`${state.key}\` creada exitosamente.**`
}]
}]
components: [
{
type: 17,
accent_color: 0x00ff00,
components: [
{
type: 10,
content: `**✅ Área \`${state.key}\` creada exitosamente.**`,
},
],
},
],
});
collector.stop('saved');
collector.stop("saved");
return;
}
} catch (e) {
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') {
collector.on("end", async (_c, r) => {
if (r === "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 {}
}
});
}
},
};
async function showBaseModal(i: ButtonInteraction, state: AreaState, editorMsg: Message, editing: boolean) {
const modal = { title: 'Base del Área', customId: 'ga_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: 'Tipo (MINE/LAGOON/FIGHT/FARM)', component: { type: ComponentType.TextInput, customId: 'type', style: TextInputStyle.Short, required: true, value: state.type ?? '' } },
] } as const;
async function showBaseModal(
i: ButtonInteraction,
state: AreaState,
editorMsg: Message,
editing: boolean
) {
const modal = {
title: "Base del Área",
customId: "ga_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: "Tipo (MINE/LAGOON/FIGHT/FARM)",
component: {
type: ComponentType.TextInput,
customId: "type",
style: TextInputStyle.Short,
required: true,
value: state.type ?? "",
},
},
{
type: ComponentType.Label,
label: "Imagen de referencia (URL, opcional)",
component: {
type: ComponentType.TextInput,
customId: "referenceImage",
style: TextInputStyle.Short,
required: false,
value:
(state.metadata &&
(state.metadata.referenceImage ||
state.metadata.image ||
state.metadata.previewImage)) ??
"",
},
},
],
} as const;
await i.showModal(modal);
try {
const sub = await i.awaitModalSubmit({ time: 300_000 });
state.name = sub.components.getTextInputValue('name').trim();
state.type = sub.components.getTextInputValue('type').trim().toUpperCase();
await sub.reply({ content: '✅ Base actualizada.', flags: MessageFlags.Ephemeral });
state.name = sub.components.getTextInputValue("name").trim();
state.type = sub.components.getTextInputValue("type").trim().toUpperCase();
try {
const ref = sub.components.getTextInputValue("referenceImage")?.trim();
if (ref && ref.length > 0) {
state.metadata = state.metadata || {};
// store as referenceImage for consumers; renderer looks at previewImage/image/referenceImage
(state.metadata as any).referenceImage = ref;
}
} catch {}
await sub.reply({
content: "✅ Base actualizada.",
flags: MessageFlags.Ephemeral,
});
// Actualizar display
await editorMsg.edit({
content: null,
flags: 32768,
components: buildEditorComponents(state, editing)
components: buildEditorComponents(state, editing),
});
} catch {}
}
async function showJsonModal(i: ButtonInteraction, state: AreaState, field: 'config'|'metadata', title: string, editorMsg: Message, editing: boolean) {
async function showJsonModal(
i: ButtonInteraction,
state: AreaState,
field: "config" | "metadata",
title: string,
editorMsg: Message,
editing: boolean
) {
const current = JSON.stringify(state[field] ?? {});
const modal = { title, customId: `ga_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: `ga_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 });
}
// Actualizar display
await editorMsg.edit({
content: null,
flags: 32768,
components: buildEditorComponents(state, editing)
components: buildEditorComponents(state, editing),
});
} catch {}
}

View File

@@ -0,0 +1,178 @@
import {
Message,
MessageFlags,
MessageComponentInteraction,
ButtonInteraction,
TextBasedChannel,
} from "discord.js";
import { ButtonStyle, ComponentType } 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";
export const command: CommandMessage = {
name: "mob-eliminar",
type: "message",
aliases: ["eliminar-mob", "mobdelete"],
cooldown: 10,
description: "Elimina un mob del servidor (requiere permisos de staff)",
category: "Minijuegos",
usage: "mob-eliminar",
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
);
if (!allowed) {
await channel.send({
content: undefined,
flags: 32768,
components: [
{
type: 17,
accent_color: 0xff0000,
components: [
{
type: 10,
content:
"❌ No tienes permisos de ManageGuild ni rol de staff.",
},
],
},
],
});
return;
}
const guildId = message.guild!.id;
try {
const { listMobsWithRows } = await import("../../../game/mobs/admin.js");
const all = await listMobsWithRows();
const localEntries = all.filter(
(e: any) => e.guildId === guildId && e.id
);
const selection = await promptKeySelection(message, {
entries: localEntries,
customIdPrefix: "mob_delete",
title: "Selecciona un mob para eliminar",
emptyText: "⚠️ No hay mobs locales configurados.",
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(" • "),
}),
});
if (!selection.entry) return;
const entry = selection.entry as any;
// confirm
const confirmMsg = await channel.send({
content: `¿Eliminar mob \`${
entry.def.name || entry.def.key
}\`? Esta acción es irreversible.`,
components: [
{
type: 1,
components: [
{
type: 2,
style: ButtonStyle.Danger,
label: "Confirmar",
custom_id: "confirm_delete",
},
{
type: 2,
style: ButtonStyle.Secondary,
label: "Cancelar",
custom_id: "cancel_delete",
},
],
},
],
});
const collector = confirmMsg.createMessageComponentCollector({
time: 60_000,
filter: (i) => i.user.id === message.author.id,
});
collector.on("collect", async (i: MessageComponentInteraction) => {
try {
if (!i.isButton()) return;
if (i.customId === "cancel_delete") {
await i.update({ content: "❌ Cancelado.", components: [] });
collector.stop("cancel");
return;
}
if (i.customId === "confirm_delete") {
await i.deferUpdate();
try {
const { deleteMob } = await import("../../../game/mobs/admin.js");
const ok = await deleteMob(entry.def.key);
if (ok) {
await i.followUp({
content: "✅ Mob eliminado.",
flags: MessageFlags.Ephemeral,
});
try {
await confirmMsg.edit({
content: "✅ Eliminado.",
components: [],
});
} catch {}
} else {
// fallback to direct Prisma delete by id
await client.prisma.mob.delete({ where: { id: entry.id } });
await i.followUp({
content: "✅ Mob eliminado (fallback).",
flags: MessageFlags.Ephemeral,
});
try {
await confirmMsg.edit({
content: "✅ Eliminado (fallback).",
components: [],
});
} catch {}
}
} catch (e: any) {
// If FK prevents deletion, inform user and suggest running cleanup script
const msg = (e && e.message) || String(e);
await i.followUp({
content: `❌ No se pudo eliminar: ${msg}`,
flags: MessageFlags.Ephemeral,
});
}
collector.stop("done");
return;
}
} catch (err) {
logger.error({ err }, "mob-eliminar");
}
});
collector.on("end", async (_c, reason) => {
if (reason === "time") {
try {
await confirmMsg.edit({
content: "⏰ Confirmación expirada.",
components: [],
});
} catch {}
}
});
} catch (e) {
logger.error({ e }, "mob-eliminar");
await channel.send({
content: "❌ Error al intentar eliminar mob.",
flags: 32768,
});
}
},
};

View File

@@ -0,0 +1,159 @@
import { Message, TextBasedChannel } from "discord.js";
import type { CommandMessage } from "../../../core/types/commands";
import { hasManageGuildOrStaff } from "../../../core/lib/permissions";
import fs from "fs";
import path from "path";
import logger from "../../../core/lib/logger";
import type Amayo from "../../../core/client";
// Helper: split text into chunks under Discord message limit (~2000)
function chunkText(text: string, size = 1900) {
const parts: string[] = [];
let i = 0;
while (i < text.length) {
parts.push(text.slice(i, i + size));
i += size;
}
return parts;
}
export const command: CommandMessage = {
name: "setup",
type: "message",
aliases: ["setup-ejemplos", "setup-demo"],
cooldown: 10,
description:
"Publica ejemplos básicos y avanzados para configurar items, mobs y áreas.",
category: "Admin",
usage: "setup [advanced]",
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
);
if (!allowed) {
await channel.send({
content: "❌ No tienes permisos de ManageGuild ni rol de staff.",
});
return;
}
const showAdvanced = args[0] === "advanced" || args.includes("advanced");
const doInit = args[0] === "init" || args.includes("init");
const doInitFull = args[0] === "init-full" || args.includes("init-full");
const initAdvanced = args.includes("advanced") && doInit;
if (doInitFull) {
await channel.send(
"Iniciando FULL setup: creando items, areas, mobs y recetas (modo idempotente). Esto puede tardar unos segundos."
);
try {
const setupMod: any = await import(
"../../../../scripts/fullServerSetup.js"
);
if (typeof setupMod.runFullServerSetup === "function") {
// Use guild id from the current guild context
await setupMod.runFullServerSetup(message.guild!.id);
await channel.send("✅ Full setup completado.");
} else {
await channel.send(
"❌ El módulo de setup completo no exporta runFullServerSetup()."
);
}
} catch (e) {
logger.error({ e }, "setup init-full failed");
await channel.send(
`❌ Error corriendo fullServerSetup: ${
(e && (e as any).message) || e
}`
);
}
return;
}
if (doInit) {
// Run seed logic in-process by importing the seed script module
await channel.send(
"Iniciando setup: creando items, areas, mobs y recetas... Esto puede tardar unos segundos."
);
try {
const seedMod: any = await import("../../../game/minigames/seed.js");
if (typeof seedMod.main === "function") {
await seedMod.main();
await channel.send("✅ Setup inicial completado.");
} else {
// fallback: try executing default export or module itself
if (typeof seedMod === "function") {
await seedMod();
await channel.send("✅ Setup inicial completado (fallback).");
} else {
await channel.send(
"❌ Módulo seed no expone main(). Ejecuta el seed manualmente."
);
}
}
} catch (e) {
logger.error({ e }, "setup init failed");
await channel.send(
`❌ Error corriendo seed: ${(e && (e as any).message) || e}`
);
}
return;
}
try {
const readPath = path.resolve(process.cwd(), "README", "Mas Ejemplos.md");
if (!fs.existsSync(readPath)) {
await channel.send("README/Mas Ejemplos.md no encontrado en el repo.");
return;
}
const raw = fs.readFileSync(readPath, "utf8");
// Extract two sections: "Flujo rápido" and "Items: creación" and the mobs section
// We'll be generous and send large chunks; the README already contains the examples.
const header = "# Guía rápida para el staff";
const basicIndex = raw.indexOf(
"## Flujo rápido: Crear un ítem con receta"
);
const itemsIndex = raw.indexOf("## Items: creación, edición y revisión");
const mobsIndex = raw.indexOf("## Mobs: enemigos y NPCs");
// Fallback: send the whole file (chunked) if parsing fails
if (basicIndex === -1 || itemsIndex === -1) {
const chunks = chunkText(raw);
for (const c of chunks) await channel.send(c);
if (!showAdvanced) return;
// advanced is basically the rest of the README; already sent
return;
}
const basicSection = raw.slice(basicIndex, itemsIndex);
const itemsSection = raw.slice(
itemsIndex,
mobsIndex === -1 ? raw.length : mobsIndex
);
const mobsSection =
mobsIndex === -1 ? "" : raw.slice(mobsIndex, raw.length);
// Send basic & items
for (const chunk of chunkText(basicSection)) await channel.send(chunk);
for (const chunk of chunkText(itemsSection)) await channel.send(chunk);
if (showAdvanced) {
for (const chunk of chunkText(mobsSection)) await channel.send(chunk);
// Also send rest of file
const restIndex = raw.indexOf("\n---\n", mobsIndex);
if (restIndex !== -1) {
const rest = raw.slice(restIndex);
for (const chunk of chunkText(rest)) await channel.send(chunk);
}
} else {
await channel.send(
"Usa `!setup advanced` para publicar la sección avanzada (mobs, crafteos avanzados y workflows)."
);
}
} catch (e) {
logger.error({ e }, "setup command failed");
await channel.send("❌ Error al publicar ejemplos. Revisa logs.");
}
},
};

View File

@@ -72,6 +72,9 @@ export async function getInventoryEntryByItemId(
});
if (existing) return existing;
if (!opts?.createIfMissing) return null;
// Asegurar que User y Guild existan antes de crear inventoryEntry para evitar
// errores de constraint por foreign keys inexistentes.
await ensureUserAndGuildExist(userId, guildId);
return prisma.inventoryEntry.create({
data: { userId, guildId, itemId, quantity: 0 },
});

View File

@@ -10,6 +10,7 @@ import {
findItemByKey,
getInventoryEntry,
} from "../economy/service";
import { findMobDef } from "../mobs/mobData";
import {
getEffectiveStats,
adjustHP,
@@ -221,6 +222,8 @@ async function applyRewards(
// Detectar efecto FATIGUE activo para penalizar SOLO monedas.
let fatigueMagnitude: number | undefined;
// prepare a container for merged modifiers so it's available later in the result
let mergedRewardModifiers: RunResult["rewardModifiers"] | undefined;
try {
const effects = await getActiveStatusEffects(userId, guildId);
const fatigue = effects.find((e) => e.type === "FATIGUE");
@@ -460,6 +463,110 @@ export async function runMinigame(
);
const mobsSpawned = await sampleMobInstances(mobs, level);
// container visible for the whole runMinigame scope so we can attach mob-derived modifiers
let mergedRewardModifiers: RunResult["rewardModifiers"] | undefined =
undefined;
// --- Aplicar rewardMods de los mobs (coinMultiplier y extraDropChance)
// Nota: applyRewards ya aplicó monedas/items base. Aquí solo aplicamos
// incrementos por rewardMods de mobs: aumentos positivos en monedas y
// posibles drops extra (aquí simplificado como +1 moneda por evento).
try {
// calcular total de monedas entregadas hasta ahora
const totalCoins = delivered
.filter((r) => r.type === "coins")
.reduce((s, r) => s + (r.amount || 0), 0);
// multiplicador compuesto por mobs (producto de coinMultiplier)
const mobCoinMultiplier = mobsSpawned.reduce((acc, m) => {
const cm = (m && (m.rewardMods as any)?.coinMultiplier) ?? 1;
return acc * (typeof cm === "number" ? cm : 1);
}, 1);
// Si el multiplicador es mayor a 1, añadimos la diferencia en monedas
if (mobCoinMultiplier > 1 && totalCoins > 0) {
const newTotal = Math.max(0, Math.floor(totalCoins * mobCoinMultiplier));
const delta = newTotal - totalCoins;
if (delta !== 0) {
await adjustCoins(userId, guildId, delta);
delivered.push({ type: "coins", amount: delta });
}
}
// extraDropChance: por cada mob, tirada para dar un drop.
// Si la definición del mob incluye `drops` (tabla de items), intentamos elegir uno.
// Si no hay drops configurados o la selección falla, otorgamos 1 coin como fallback.
let extraDropsGiven = 0;
for (const m of mobsSpawned) {
const chance = (m && (m.rewardMods as any)?.extraDropChance) ?? 0;
if (typeof chance === "number" && chance > 0 && Math.random() < chance) {
try {
// Intentar usar la tabla `drops` si existe en la definición original (buscada via findMobDef)
const def = (m && findMobDef(m.key)) as any;
const drops = def?.drops ?? def?.rewards ?? null;
let granted = false;
if (drops) {
// Formato A (ponderado): [{ itemKey, qty?, weight? }, ...]
if (Array.isArray(drops) && drops.length > 0) {
const total = drops.reduce(
(s: number, d: any) => s + (Number(d.weight) || 1),
0
);
let r = Math.random() * total;
for (const d of drops) {
const w = Number(d.weight) || 1;
r -= w;
if (r <= 0) {
const sel = d.itemKey;
const qty = Number(d.qty) || 1;
await addItemByKey(userId, guildId, sel, qty);
delivered.push({ type: "item", itemKey: sel, qty });
granted = true;
break;
}
}
} else if (typeof drops === "object") {
// Formato B (map simple): { itemKey: qty }
const keys = Object.keys(drops || {});
if (keys.length > 0) {
const sel = keys[Math.floor(Math.random() * keys.length)];
const qty = Number((drops as any)[sel]) || 1;
await addItemByKey(userId, guildId, sel, qty);
delivered.push({ type: "item", itemKey: sel, qty });
granted = true;
}
}
}
if (!granted) {
// fallback: coin
await adjustCoins(userId, guildId, 1);
delivered.push({ type: "coins", amount: 1 });
}
extraDropsGiven++;
} catch (e) {
// en error, conceder fallback monetario pero no interrumpir
try {
await adjustCoins(userId, guildId, 1);
delivered.push({ type: "coins", amount: 1 });
extraDropsGiven++;
} catch {}
}
}
}
// Construir un objeto mergedRewardModifiers en lugar de mutar rewardModifiers
mergedRewardModifiers = {
...((rewardModifiers as any) || {}),
mobCoinMultiplier,
extraDropsGiven:
((rewardModifiers as any)?.extraDropsGiven || 0) + extraDropsGiven,
} as any;
} catch (err) {
// No queremos que fallos menores de rewardMods rompan la ejecución
// eslint-disable-next-line no-console
console.warn("applyMobRewardMods: failed:", (err as any)?.message ?? err);
}
// Reducir durabilidad de herramienta si se usó
let toolInfo: RunResult["tool"] | undefined;
if (reqRes.toolKeyUsed) {
@@ -845,13 +952,117 @@ export async function runMinigame(
}
}
// --- Aplicar rewardMods provenientes de mobs derrotados (post-combate)
try {
if (combatSummary) {
const defeated = (combatSummary.mobs || []).filter((m) => m.defeated);
if (defeated.length > 0) {
// multiplicador compuesto solo por mobs derrotados
const defeatedMultiplier = defeated.reduce((acc, dm) => {
try {
const def = findMobDef(dm.mobKey) as any;
const cm =
(def && def.rewardMods && def.rewardMods.coinMultiplier) ?? 1;
return acc * (typeof cm === "number" ? cm : 1);
} catch {
return acc;
}
}, 1);
const totalCoinsBefore = delivered
.filter((r) => r.type === "coins")
.reduce((s, r) => s + (r.amount || 0), 0);
if (defeatedMultiplier > 1 && totalCoinsBefore > 0) {
const newTotal = Math.max(
0,
Math.floor(totalCoinsBefore * defeatedMultiplier)
);
const delta = newTotal - totalCoinsBefore;
if (delta !== 0) {
await adjustCoins(userId, guildId, delta);
delivered.push({ type: "coins", amount: delta });
}
}
// extra drops por cada mob derrotado
let extraDropsFromCombat = 0;
for (const dm of defeated) {
try {
const def = findMobDef(dm.mobKey) as any;
const chance =
(def && def.rewardMods && def.rewardMods.extraDropChance) ?? 0;
if (
typeof chance === "number" &&
chance > 0 &&
Math.random() < chance
) {
// intentar dropear item similar al flujo de minigame
const drops = def?.drops ?? def?.rewards ?? null;
let granted = false;
if (drops) {
if (Array.isArray(drops) && drops.length > 0) {
const total = drops.reduce(
(s: number, d: any) => s + (Number(d.weight) || 1),
0
);
let r = Math.random() * total;
for (const d of drops) {
const w = Number(d.weight) || 1;
r -= w;
if (r <= 0) {
const sel = d.itemKey;
const qty = Number(d.qty) || 1;
await addItemByKey(userId, guildId, sel, qty);
delivered.push({ type: "item", itemKey: sel, qty });
granted = true;
break;
}
}
} else if (typeof drops === "object") {
const keys = Object.keys(drops || {});
if (keys.length > 0) {
const sel = keys[Math.floor(Math.random() * keys.length)];
const qty = Number((drops as any)[sel]) || 1;
await addItemByKey(userId, guildId, sel, qty);
delivered.push({ type: "item", itemKey: sel, qty });
granted = true;
}
}
}
if (!granted) {
await adjustCoins(userId, guildId, 1);
delivered.push({ type: "coins", amount: 1 });
}
extraDropsFromCombat++;
}
} catch {}
}
// Fusionar con mergedRewardModifiers
mergedRewardModifiers = {
...((mergedRewardModifiers as any) || (rewardModifiers as any) || {}),
defeatedMobCoinMultiplier:
((mergedRewardModifiers as any)?.defeatedMobCoinMultiplier || 1) *
(defeatedMultiplier || 1),
extraDropsFromCombat:
((mergedRewardModifiers as any)?.extraDropsFromCombat || 0) +
extraDropsFromCombat,
} as any;
}
}
} catch (e) {
// no bloquear ejecución por fallos en recompensas secundarias
}
const resultJson: Prisma.InputJsonValue = {
rewards: delivered,
mobs: mobsSpawned.map((m) => m?.key ?? "unknown"),
tool: toolInfo,
weaponTool: weaponToolInfo,
combat: combatSummary,
rewardModifiers,
rewardModifiers:
mergedRewardModifiers ?? (rewardModifiers as any) ?? undefined,
notes: "auto",
} as unknown as Prisma.InputJsonValue;
@@ -900,7 +1111,7 @@ export async function runMinigame(
tool: toolInfo,
weaponTool: weaponToolInfo,
combat: combatSummary,
rewardModifiers,
rewardModifiers: mergedRewardModifiers,
};
}

View File

@@ -0,0 +1,32 @@
import { findMobDef } from "../mobs/mobData";
export function pickDropFromDef(
def: any
): { itemKey: string; qty: number } | null {
if (!def) return null;
const drops = def.drops ?? def.rewards ?? null;
if (!drops) return null;
if (Array.isArray(drops) && drops.length > 0) {
const total = drops.reduce(
(s: number, d: any) => s + (Number(d.weight) || 1),
0
);
let r = Math.random() * total;
for (const d of drops) {
const w = Number(d.weight) || 1;
r -= w;
if (r <= 0) return { itemKey: d.itemKey, qty: Number(d.qty) || 1 };
}
return {
itemKey: drops[drops.length - 1].itemKey,
qty: Number(drops[drops.length - 1].qty) || 1,
};
}
if (typeof drops === "object") {
const keys = Object.keys(drops || {});
if (keys.length === 0) return null;
const sel = keys[Math.floor(Math.random() * keys.length)];
return { itemKey: sel, qty: Number(drops[sel]) || 1 };
}
return null;
}

View File

@@ -1,40 +1,11 @@
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(),
});
import {
BaseMobDefinition,
MOB_DEFINITIONS,
findMobDef,
BaseMobDefinitionSchema,
} from "./mobData";
type MobInput = z.infer<typeof BaseMobDefinitionSchema>;

View File

@@ -29,6 +29,17 @@ export interface BaseMobDefinition {
};
}
// Nota sobre 'drops':
// El motor soporta, además de la definición básica del mob, un campo opcional `drops`
// que puede vivir en la definición del mob (o en la fila DB `Mob.drops`). Hay dos formatos
// soportados por conveniencia:
// 1) Mapa simple (object): { "item.key": qty, "other.key": qty }
// - Selección aleatoria entre las keys y entrega qty del item seleccionado.
// 2) Array ponderado: [{ itemKey: string, qty?: number, weight?: number }, ...]
// - Se realiza una tirada ponderada usando `weight` (por defecto 1) y se entrega `qty`.
// Si no hay drops configurados o la selección falla, la lógica actual aplica un fallback que
// otorga 1 moneda.
// Ejemplos iniciales - se pueden ir expandiendo
export const MOB_DEFINITIONS: BaseMobDefinition[] = [
{
@@ -114,7 +125,7 @@ export function listMobKeys(): string[] {
import { prisma } from "../../core/database/prisma";
import { z } from "zod";
const BaseMobDefinitionSchema = z.object({
export const BaseMobDefinitionSchema = z.object({
key: z.string(),
name: z.string(),
tier: z.number().int().nonnegative(),