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:
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
|
||||
178
src/commands/messages/game/mobDelete.ts
Normal file
178
src/commands/messages/game/mobDelete.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
159
src/commands/messages/game/setup.ts
Normal file
159
src/commands/messages/game/setup.ts
Normal 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.");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
32
src/game/minigames/testHelpers.ts
Normal file
32
src/game/minigames/testHelpers.ts
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user