Refactor economy service and types for improved readability and functionality
- Reformatted code for consistent styling and indentation in service.ts - Enhanced item reward structure in types.ts to include probability for chest rewards - Added randomization modes for chest rewards: 'all', 'single', and 'roll-each' - Updated functions in service.ts to handle new reward structures and improve error handling - Ensured better organization and clarity in function parameters and return types
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
import { Message, MessageFlags, MessageComponentInteraction, ButtonInteraction, TextBasedChannel } from 'discord.js';
|
||||
import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10';
|
||||
import type { CommandMessage } from '../../../core/types/commands';
|
||||
import { hasManageGuildOrStaff } from '../../../core/lib/permissions';
|
||||
import logger from '../../../core/lib/logger';
|
||||
import type Amayo from '../../../core/client';
|
||||
import {
|
||||
Message,
|
||||
MessageFlags,
|
||||
MessageComponentInteraction,
|
||||
ButtonInteraction,
|
||||
TextBasedChannel,
|
||||
} from "discord.js";
|
||||
import {
|
||||
ComponentType,
|
||||
TextInputStyle,
|
||||
ButtonStyle,
|
||||
} from "discord-api-types/v10";
|
||||
import type { CommandMessage } from "../../../core/types/commands";
|
||||
import { hasManageGuildOrStaff } from "../../../core/lib/permissions";
|
||||
import logger from "../../../core/lib/logger";
|
||||
import type Amayo from "../../../core/client";
|
||||
|
||||
interface ItemEditorState {
|
||||
key: string;
|
||||
@@ -21,32 +31,44 @@ interface ItemEditorState {
|
||||
ingredients: Array<{ itemKey: string; quantity: number }>;
|
||||
productQuantity: number;
|
||||
};
|
||||
// Derivado de props.global (solo owner puede establecerlo)
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
export const command: CommandMessage = {
|
||||
name: 'item-crear',
|
||||
type: 'message',
|
||||
aliases: ['crear-item','itemcreate'],
|
||||
name: "item-crear",
|
||||
type: "message",
|
||||
aliases: ["crear-item", "itemcreate"],
|
||||
cooldown: 10,
|
||||
description: 'Crea un EconomyItem para este servidor con un pequeño editor interactivo.',
|
||||
category: 'Economía',
|
||||
usage: 'item-crear <key-única>',
|
||||
description:
|
||||
"Crea un EconomyItem para este servidor con un pequeño editor interactivo.",
|
||||
category: "Economía",
|
||||
usage: "item-crear <key-única>",
|
||||
run: async (message: Message, args: string[], client: Amayo) => {
|
||||
const channel = message.channel as TextBasedChannel & { send: Function };
|
||||
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, client.prisma);
|
||||
const allowed = await hasManageGuildOrStaff(
|
||||
message.member,
|
||||
message.guild!.id,
|
||||
client.prisma
|
||||
);
|
||||
if (!allowed) {
|
||||
await (channel.send as any)({
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: [{
|
||||
type: 17,
|
||||
accent_color: 0xFF0000,
|
||||
components: [{
|
||||
type: 10,
|
||||
content: '❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.'
|
||||
}]
|
||||
}],
|
||||
reply: { messageReference: message.id }
|
||||
components: [
|
||||
{
|
||||
type: 17,
|
||||
accent_color: 0xff0000,
|
||||
components: [
|
||||
{
|
||||
type: 10,
|
||||
content:
|
||||
"❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
reply: { messageReference: message.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -56,35 +78,47 @@ 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: `!item-crear <key-única>`'
|
||||
}]
|
||||
}],
|
||||
reply: { messageReference: message.id }
|
||||
components: [
|
||||
{
|
||||
type: 17,
|
||||
accent_color: 0xffa500,
|
||||
components: [
|
||||
{
|
||||
type: 10,
|
||||
content:
|
||||
"⚠️ **Uso Incorrecto**\n└ Uso: `!item-crear <key-única>`",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
reply: { messageReference: message.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const guildId = message.guild!.id;
|
||||
|
||||
const exists = await client.prisma.economyItem.findFirst({ where: { key, guildId } });
|
||||
const exists = await client.prisma.economyItem.findFirst({
|
||||
where: { key, guildId },
|
||||
});
|
||||
if (exists) {
|
||||
await (channel.send as any)({
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: [{
|
||||
type: 17,
|
||||
accent_color: 0xFF0000,
|
||||
components: [{
|
||||
type: 10,
|
||||
content: '❌ **Item Ya Existe**\n└ Ya existe un item con esa key en este servidor.'
|
||||
}]
|
||||
}],
|
||||
reply: { messageReference: message.id }
|
||||
components: [
|
||||
{
|
||||
type: 17,
|
||||
accent_color: 0xff0000,
|
||||
components: [
|
||||
{
|
||||
type: 10,
|
||||
content:
|
||||
"❌ **Item Ya Existe**\n└ Ya existe un item con esa key en este servidor.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
reply: { messageReference: message.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -98,55 +132,59 @@ export const command: CommandMessage = {
|
||||
recipe: {
|
||||
enabled: false,
|
||||
ingredients: [],
|
||||
productQuantity: 1
|
||||
}
|
||||
productQuantity: 1,
|
||||
},
|
||||
isGlobal: false,
|
||||
};
|
||||
|
||||
const buildEditorDisplay = () => {
|
||||
const baseInfo = [
|
||||
`**Nombre:** ${state.name || '*Sin definir*'}`,
|
||||
`**Descripción:** ${state.description || '*Sin definir*'}`,
|
||||
`**Categoría:** ${state.category || '*Sin definir*'}`,
|
||||
`**Icon URL:** ${state.icon || '*Sin definir*'}`,
|
||||
`**Stackable:** ${state.stackable ? 'Sí' : 'No'}`,
|
||||
`**Máx. Inventario:** ${state.maxPerInventory ?? 'Ilimitado'}`,
|
||||
].join('\n');
|
||||
`**Nombre:** ${state.name || "*Sin definir*"}`,
|
||||
`**Descripción:** ${state.description || "*Sin definir*"}`,
|
||||
`**Categoría:** ${state.category || "*Sin definir*"}`,
|
||||
`**Icon URL:** ${state.icon || "*Sin definir*"}`,
|
||||
`**Stackable:** ${state.stackable ? "Sí" : "No"}`,
|
||||
`**Máx. Inventario:** ${state.maxPerInventory ?? "Ilimitado"}`,
|
||||
`**Global:** ${state.isGlobal ? "Sí" : "No"}`,
|
||||
].join("\n");
|
||||
|
||||
const tagsInfo = `**Tags:** ${state.tags.length > 0 ? state.tags.join(', ') : '*Ninguno*'}`;
|
||||
const tagsInfo = `**Tags:** ${
|
||||
state.tags.length > 0 ? state.tags.join(", ") : "*Ninguno*"
|
||||
}`;
|
||||
const propsJson = JSON.stringify(state.props ?? {}, null, 2);
|
||||
const recipeInfo = state.recipe?.enabled
|
||||
const recipeInfo = state.recipe?.enabled
|
||||
? `**Receta:** Habilitada (${state.recipe.ingredients.length} ingredientes → ${state.recipe.productQuantity} unidades)`
|
||||
: `**Receta:** Deshabilitada`;
|
||||
|
||||
return {
|
||||
type: 17,
|
||||
accent_color: 0x00D9FF,
|
||||
accent_color: 0x00d9ff,
|
||||
components: [
|
||||
{
|
||||
type: 10,
|
||||
content: `# 🛠️ Editor de Item: \`${key}\``
|
||||
content: `# 🛠️ Editor de Item: \`${key}\``,
|
||||
},
|
||||
{ type: 14, divider: true },
|
||||
{
|
||||
type: 10,
|
||||
content: baseInfo
|
||||
content: baseInfo,
|
||||
},
|
||||
{ type: 14, divider: true },
|
||||
{
|
||||
type: 10,
|
||||
content: tagsInfo
|
||||
content: tagsInfo,
|
||||
},
|
||||
{ type: 14, divider: true },
|
||||
{
|
||||
type: 10,
|
||||
content: recipeInfo
|
||||
content: recipeInfo,
|
||||
},
|
||||
{ type: 14, divider: true },
|
||||
{
|
||||
type: 10,
|
||||
content: `**Props (JSON):**\n\`\`\`json\n${propsJson}\n\`\`\``
|
||||
}
|
||||
]
|
||||
content: `**Props (JSON):**\n\`\`\`json\n${propsJson}\n\`\`\``,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -155,77 +193,165 @@ export const command: CommandMessage = {
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
{ type: 2, style: ButtonStyle.Primary, label: 'Base', custom_id: 'it_base' },
|
||||
{ type: 2, style: ButtonStyle.Secondary, label: 'Tags', custom_id: 'it_tags' },
|
||||
{ type: 2, style: ButtonStyle.Secondary, label: 'Receta', custom_id: 'it_recipe' },
|
||||
{ type: 2, style: ButtonStyle.Secondary, label: 'Props (JSON)', custom_id: 'it_props' },
|
||||
]
|
||||
{
|
||||
type: 2,
|
||||
style: ButtonStyle.Primary,
|
||||
label: "Base",
|
||||
custom_id: "it_base",
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
style: ButtonStyle.Secondary,
|
||||
label: "Tags",
|
||||
custom_id: "it_tags",
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
style: ButtonStyle.Secondary,
|
||||
label: "Receta",
|
||||
custom_id: "it_recipe",
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
style: ButtonStyle.Secondary,
|
||||
label: "Props (JSON)",
|
||||
custom_id: "it_props",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
{ type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'it_save' },
|
||||
{ type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'it_cancel' },
|
||||
]
|
||||
}
|
||||
{
|
||||
type: 2,
|
||||
style: ButtonStyle.Success,
|
||||
label: "Guardar",
|
||||
custom_id: "it_save",
|
||||
},
|
||||
{
|
||||
type: 2,
|
||||
style: ButtonStyle.Danger,
|
||||
label: "Cancelar",
|
||||
custom_id: "it_cancel",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const editorMsg = await (channel.send as any)({
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: buildEditorComponents(),
|
||||
reply: { messageReference: message.id }
|
||||
reply: { messageReference: message.id },
|
||||
});
|
||||
|
||||
const collector = editorMsg.createMessageComponentCollector({ time: 30 * 60_000, filter: (i) => i.user.id === message.author.id });
|
||||
const collector = editorMsg.createMessageComponentCollector({
|
||||
time: 30 * 60_000,
|
||||
filter: (i) => i.user.id === message.author.id,
|
||||
});
|
||||
|
||||
collector.on('collect', async (i: MessageComponentInteraction) => {
|
||||
collector.on("collect", async (i: MessageComponentInteraction) => {
|
||||
try {
|
||||
if (!i.isButton()) return;
|
||||
if (i.customId === 'it_cancel') {
|
||||
if (i.customId === "it_cancel") {
|
||||
await i.deferUpdate();
|
||||
await editorMsg.edit({
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: [{
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: [
|
||||
{
|
||||
type: 17,
|
||||
accent_color: 0xFF0000,
|
||||
components: [{
|
||||
type: 10,
|
||||
content: '**❌ Editor cancelado.**'
|
||||
}]
|
||||
}]
|
||||
});
|
||||
collector.stop('cancel');
|
||||
accent_color: 0xff0000,
|
||||
components: [
|
||||
{
|
||||
type: 10,
|
||||
content: "**❌ Editor cancelado.**",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
collector.stop("cancel");
|
||||
return;
|
||||
}
|
||||
if (i.customId === 'it_base') {
|
||||
await showBaseModal(i as ButtonInteraction, state, editorMsg, buildEditorComponents);
|
||||
if (i.customId === "it_base") {
|
||||
await showBaseModal(
|
||||
i as ButtonInteraction,
|
||||
state,
|
||||
editorMsg,
|
||||
buildEditorComponents
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (i.customId === 'it_tags') {
|
||||
await showTagsModal(i as ButtonInteraction, state, editorMsg, buildEditorComponents);
|
||||
if (i.customId === "it_tags") {
|
||||
await showTagsModal(
|
||||
i as ButtonInteraction,
|
||||
state,
|
||||
editorMsg,
|
||||
buildEditorComponents
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (i.customId === 'it_recipe') {
|
||||
await showRecipeModal(i as ButtonInteraction, state, editorMsg, buildEditorComponents, client);
|
||||
if (i.customId === "it_recipe") {
|
||||
await showRecipeModal(
|
||||
i as ButtonInteraction,
|
||||
state,
|
||||
editorMsg,
|
||||
buildEditorComponents,
|
||||
client
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (i.customId === 'it_props') {
|
||||
await showPropsModal(i as ButtonInteraction, state, editorMsg, buildEditorComponents);
|
||||
if (i.customId === "it_props") {
|
||||
await showPropsModal(
|
||||
i as ButtonInteraction,
|
||||
state,
|
||||
editorMsg,
|
||||
buildEditorComponents
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (i.customId === 'it_save') {
|
||||
if (i.customId === "it_save") {
|
||||
// Validar
|
||||
if (!state.name) {
|
||||
await i.reply({ content: '❌ Falta el nombre del item (configura en Base).', flags: MessageFlags.Ephemeral });
|
||||
await i.reply({
|
||||
content: "❌ Falta el nombre del item (configura en Base).",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Revisar bandera global en props (puede haberse puesto manualmente en JSON)
|
||||
state.isGlobal = !!state.props?.global;
|
||||
const BOT_OWNER_ID = "327207082203938818";
|
||||
if (state.isGlobal && i.user.id !== BOT_OWNER_ID) {
|
||||
await i.reply({
|
||||
content:
|
||||
"❌ No puedes crear ítems globales. Solo el owner del bot.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Si es global, usar guildId = null y verificar que no exista ya global con esa key
|
||||
let targetGuildId: string | null = message.guild!.id;
|
||||
if (state.isGlobal) {
|
||||
const existsGlobal = await client.prisma.economyItem.findFirst({
|
||||
where: { key: state.key, guildId: null },
|
||||
});
|
||||
if (existsGlobal) {
|
||||
await i.reply({
|
||||
content: "❌ Ya existe un ítem global con esa key.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
return;
|
||||
}
|
||||
targetGuildId = null;
|
||||
}
|
||||
|
||||
// Guardar item
|
||||
const createdItem = await client.prisma.economyItem.create({
|
||||
data: {
|
||||
guildId,
|
||||
guildId: targetGuildId,
|
||||
key: state.key,
|
||||
name: state.name!,
|
||||
description: state.description,
|
||||
@@ -237,106 +363,192 @@ export const command: CommandMessage = {
|
||||
props: state.props ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Guardar receta si está habilitada
|
||||
if (state.recipe?.enabled && state.recipe.ingredients.length > 0) {
|
||||
try {
|
||||
// Resolver itemIds de los ingredientes
|
||||
const ingredientsData: Array<{ itemId: string; quantity: number }> = [];
|
||||
const ingredientsData: Array<{
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
}> = [];
|
||||
for (const ing of state.recipe.ingredients) {
|
||||
const item = await client.prisma.economyItem.findFirst({
|
||||
where: {
|
||||
key: ing.itemKey,
|
||||
OR: [{ guildId }, { guildId: null }]
|
||||
OR: [{ guildId }, { guildId: null }],
|
||||
},
|
||||
orderBy: [{ guildId: 'desc' }]
|
||||
orderBy: [{ guildId: "desc" }],
|
||||
});
|
||||
if (!item) {
|
||||
throw new Error(`Ingrediente no encontrado: ${ing.itemKey}`);
|
||||
}
|
||||
ingredientsData.push({
|
||||
itemId: item.id,
|
||||
quantity: ing.quantity
|
||||
quantity: ing.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Crear la receta
|
||||
await client.prisma.itemRecipe.create({
|
||||
data: {
|
||||
productItemId: createdItem.id,
|
||||
productQuantity: state.recipe.productQuantity,
|
||||
ingredients: {
|
||||
create: ingredientsData
|
||||
}
|
||||
}
|
||||
create: ingredientsData,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn({ err }, 'Error creando receta para item');
|
||||
await i.followUp({ content: `⚠️ Item creado pero falló la receta: ${err.message}`, flags: MessageFlags.Ephemeral });
|
||||
logger.warn({ err }, "Error creando receta para item");
|
||||
await i.followUp({
|
||||
content: `⚠️ Item creado pero falló la receta: ${err.message}`,
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
}
|
||||
}
|
||||
await i.reply({ content: '✅ Item guardado!', flags: MessageFlags.Ephemeral });
|
||||
await editorMsg.edit({
|
||||
await i.reply({
|
||||
content: "✅ Item guardado!",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
await editorMsg.edit({
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: [{
|
||||
type: 17,
|
||||
accent_color: 0x00FF00,
|
||||
components: [{
|
||||
type: 10,
|
||||
content: `✅ **Item Creado**\n└ Item \`${state.key}\` creado exitosamente.`
|
||||
}]
|
||||
}]
|
||||
components: [
|
||||
{
|
||||
type: 17,
|
||||
accent_color: 0x00ff00,
|
||||
components: [
|
||||
{
|
||||
type: 10,
|
||||
content: `✅ **Item Creado**\n└ Item \`${
|
||||
state.key
|
||||
}\` creado exitosamente.${
|
||||
state.isGlobal ? " (Global)" : ""
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
collector.stop('saved');
|
||||
collector.stop("saved");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'item-crear interaction error');
|
||||
if (!i.deferred && !i.replied) await i.reply({ content: '❌ Error procesando la acción.', flags: MessageFlags.Ephemeral });
|
||||
logger.error({ err }, "item-crear interaction error");
|
||||
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({
|
||||
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.**'
|
||||
}]
|
||||
}]
|
||||
}); } catch {}
|
||||
components: [
|
||||
{
|
||||
type: 17,
|
||||
accent_color: 0xffa500,
|
||||
components: [
|
||||
{
|
||||
type: 10,
|
||||
content: "**⏰ Editor expirado.**",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async function showBaseModal(i: ButtonInteraction, state: ItemEditorState, editorMsg: any, buildComponents: () => any[]) {
|
||||
async function showBaseModal(
|
||||
i: ButtonInteraction,
|
||||
state: ItemEditorState,
|
||||
editorMsg: any,
|
||||
buildComponents: () => any[]
|
||||
) {
|
||||
const modal = {
|
||||
title: 'Configuración base del Item',
|
||||
customId: 'it_base_modal',
|
||||
title: "Configuración base del Item",
|
||||
customId: "it_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: 'Descripción', component: { type: ComponentType.TextInput, customId: 'desc', style: TextInputStyle.Paragraph, required: false, value: state.description ?? '' } },
|
||||
{ type: ComponentType.Label, label: 'Categoría', component: { type: ComponentType.TextInput, customId: 'cat', style: TextInputStyle.Short, required: false, value: state.category ?? '' } },
|
||||
{ type: ComponentType.Label, label: 'Icon URL', component: { type: ComponentType.TextInput, customId: 'icon', style: TextInputStyle.Short, required: false, value: state.icon ?? '' } },
|
||||
{ type: ComponentType.Label, label: 'Stackable y Máx inventario', component: { type: ComponentType.TextInput, customId: 'stack_max', style: TextInputStyle.Short, required: false, placeholder: 'true,10', value: state.stackable !== undefined ? `${state.stackable},${state.maxPerInventory ?? ''}` : '' } },
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: "Nombre",
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: "name",
|
||||
style: TextInputStyle.Short,
|
||||
required: true,
|
||||
value: state.name ?? "",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: "Descripción",
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: "desc",
|
||||
style: TextInputStyle.Paragraph,
|
||||
required: false,
|
||||
value: state.description ?? "",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: "Categoría",
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: "cat",
|
||||
style: TextInputStyle.Short,
|
||||
required: false,
|
||||
value: state.category ?? "",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: "Icon URL",
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: "icon",
|
||||
style: TextInputStyle.Short,
|
||||
required: false,
|
||||
value: state.icon ?? "",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: "Stackable y Máx inventario",
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: "stack_max",
|
||||
style: TextInputStyle.Short,
|
||||
required: false,
|
||||
placeholder: "true,10",
|
||||
value:
|
||||
state.stackable !== undefined
|
||||
? `${state.stackable},${state.maxPerInventory ?? ""}`
|
||||
: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
await i.showModal(modal);
|
||||
try {
|
||||
const sub = await i.awaitModalSubmit({ time: 300_000 });
|
||||
const name = sub.components.getTextInputValue('name').trim();
|
||||
const desc = sub.components.getTextInputValue('desc').trim();
|
||||
const cat = sub.components.getTextInputValue('cat').trim();
|
||||
const icon = sub.components.getTextInputValue('icon').trim();
|
||||
const stackMax = sub.components.getTextInputValue('stack_max').trim();
|
||||
const name = sub.components.getTextInputValue("name").trim();
|
||||
const desc = sub.components.getTextInputValue("desc").trim();
|
||||
const cat = sub.components.getTextInputValue("cat").trim();
|
||||
const icon = sub.components.getTextInputValue("icon").trim();
|
||||
const stackMax = sub.components.getTextInputValue("stack_max").trim();
|
||||
|
||||
state.name = name;
|
||||
state.description = desc || undefined;
|
||||
@@ -344,8 +556,8 @@ async function showBaseModal(i: ButtonInteraction, state: ItemEditorState, edito
|
||||
state.icon = icon || undefined;
|
||||
|
||||
if (stackMax) {
|
||||
const [s, m] = stackMax.split(',');
|
||||
state.stackable = String(s).toLowerCase() !== 'false';
|
||||
const [s, m] = stackMax.split(",");
|
||||
state.stackable = String(s).toLowerCase() !== "false";
|
||||
const mv = m?.trim();
|
||||
state.maxPerInventory = mv ? Math.max(0, parseInt(mv, 10) || 0) : null;
|
||||
}
|
||||
@@ -354,162 +566,226 @@ async function showBaseModal(i: ButtonInteraction, state: ItemEditorState, edito
|
||||
await editorMsg.edit({
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: buildComponents()
|
||||
components: buildComponents(),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function showTagsModal(i: ButtonInteraction, state: ItemEditorState, editorMsg: any, buildComponents: () => any[]) {
|
||||
async function showTagsModal(
|
||||
i: ButtonInteraction,
|
||||
state: ItemEditorState,
|
||||
editorMsg: any,
|
||||
buildComponents: () => any[]
|
||||
) {
|
||||
const modal = {
|
||||
title: 'Tags del Item (separados por coma)',
|
||||
customId: 'it_tags_modal',
|
||||
title: "Tags del Item (separados por coma)",
|
||||
customId: "it_tags_modal",
|
||||
components: [
|
||||
{ type: ComponentType.Label, label: 'Tags', component: { type: ComponentType.TextInput, customId: 'tags', style: TextInputStyle.Paragraph, required: false, value: state.tags.join(', ') } },
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: "Tags",
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: "tags",
|
||||
style: TextInputStyle.Paragraph,
|
||||
required: false,
|
||||
value: state.tags.join(", "),
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
await i.showModal(modal);
|
||||
try {
|
||||
const sub = await i.awaitModalSubmit({ time: 300_000 });
|
||||
const tags = sub.components.getTextInputValue('tags');
|
||||
state.tags = tags ? tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||
const tags = sub.components.getTextInputValue("tags");
|
||||
state.tags = tags
|
||||
? tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
await sub.deferUpdate();
|
||||
await editorMsg.edit({
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: buildComponents()
|
||||
components: buildComponents(),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function showPropsModal(i: ButtonInteraction, state: ItemEditorState, editorMsg: any, buildComponents: () => any[]) {
|
||||
const template = state.props && Object.keys(state.props).length ? JSON.stringify(state.props) : JSON.stringify({
|
||||
tool: undefined,
|
||||
breakable: undefined,
|
||||
chest: undefined,
|
||||
eventCurrency: undefined,
|
||||
passiveEffects: [],
|
||||
mutationPolicy: undefined,
|
||||
craftingOnly: false,
|
||||
food: undefined,
|
||||
damage: undefined,
|
||||
defense: undefined,
|
||||
maxHpBonus: undefined,
|
||||
});
|
||||
async function showPropsModal(
|
||||
i: ButtonInteraction,
|
||||
state: ItemEditorState,
|
||||
editorMsg: any,
|
||||
buildComponents: () => any[]
|
||||
) {
|
||||
const template =
|
||||
state.props && Object.keys(state.props).length
|
||||
? JSON.stringify(state.props)
|
||||
: JSON.stringify({
|
||||
tool: undefined,
|
||||
breakable: undefined,
|
||||
chest: undefined,
|
||||
eventCurrency: undefined,
|
||||
passiveEffects: [],
|
||||
mutationPolicy: undefined,
|
||||
craftingOnly: false,
|
||||
food: undefined,
|
||||
damage: undefined,
|
||||
defense: undefined,
|
||||
maxHpBonus: undefined,
|
||||
});
|
||||
const modal = {
|
||||
title: 'Props (JSON) del Item',
|
||||
customId: 'it_props_modal',
|
||||
title: "Props (JSON) del Item",
|
||||
customId: "it_props_modal",
|
||||
components: [
|
||||
{ type: ComponentType.Label, label: 'JSON', component: { type: ComponentType.TextInput, customId: 'props', style: TextInputStyle.Paragraph, required: false, value: template.slice(0,4000) } },
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: "JSON",
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: "props",
|
||||
style: TextInputStyle.Paragraph,
|
||||
required: false,
|
||||
value: template.slice(0, 4000),
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
await i.showModal(modal);
|
||||
try {
|
||||
const sub = await i.awaitModalSubmit({ time: 300_000 });
|
||||
const raw = sub.components.getTextInputValue('props');
|
||||
const raw = sub.components.getTextInputValue("props");
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
state.props = parsed;
|
||||
await sub.deferUpdate();
|
||||
await sub.deferUpdate();
|
||||
await editorMsg.edit({
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: buildComponents()
|
||||
components: buildComponents(),
|
||||
});
|
||||
} catch (e) {
|
||||
await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral });
|
||||
await sub.reply({
|
||||
content: "❌ JSON inválido.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
state.props = {};
|
||||
await sub.reply({ content: 'ℹ️ Props limpiados.', flags: MessageFlags.Ephemeral });
|
||||
await sub.reply({
|
||||
content: "ℹ️ Props limpiados.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
try {
|
||||
await editorMsg.edit({
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: buildComponents()
|
||||
components: buildComponents(),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function showRecipeModal(i: ButtonInteraction, state: ItemEditorState, editorMsg: any, buildComponents: () => any[], client: Amayo) {
|
||||
const currentRecipe = state.recipe || { enabled: false, ingredients: [], productQuantity: 1 };
|
||||
const ingredientsStr = currentRecipe.ingredients.map(ing => `${ing.itemKey}:${ing.quantity}`).join(', ');
|
||||
|
||||
async function showRecipeModal(
|
||||
i: ButtonInteraction,
|
||||
state: ItemEditorState,
|
||||
editorMsg: any,
|
||||
buildComponents: () => any[],
|
||||
client: Amayo
|
||||
) {
|
||||
const currentRecipe = state.recipe || {
|
||||
enabled: false,
|
||||
ingredients: [],
|
||||
productQuantity: 1,
|
||||
};
|
||||
const ingredientsStr = currentRecipe.ingredients
|
||||
.map((ing) => `${ing.itemKey}:${ing.quantity}`)
|
||||
.join(", ");
|
||||
|
||||
const modal = {
|
||||
title: 'Receta de Crafteo',
|
||||
customId: 'it_recipe_modal',
|
||||
title: "Receta de Crafteo",
|
||||
customId: "it_recipe_modal",
|
||||
components: [
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: 'Habilitar receta? (true/false)',
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: 'enabled',
|
||||
style: TextInputStyle.Short,
|
||||
required: false,
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: "Habilitar receta? (true/false)",
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: "enabled",
|
||||
style: TextInputStyle.Short,
|
||||
required: false,
|
||||
value: String(currentRecipe.enabled),
|
||||
placeholder: 'true o false'
|
||||
}
|
||||
placeholder: "true o false",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: 'Cantidad que produce',
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: 'quantity',
|
||||
style: TextInputStyle.Short,
|
||||
required: false,
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: "Cantidad que produce",
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: "quantity",
|
||||
style: TextInputStyle.Short,
|
||||
required: false,
|
||||
value: String(currentRecipe.productQuantity),
|
||||
placeholder: '1'
|
||||
}
|
||||
placeholder: "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: 'Ingredientes (itemKey:qty, ...)',
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: 'ingredients',
|
||||
style: TextInputStyle.Paragraph,
|
||||
required: false,
|
||||
{
|
||||
type: ComponentType.Label,
|
||||
label: "Ingredientes (itemKey:qty, ...)",
|
||||
component: {
|
||||
type: ComponentType.TextInput,
|
||||
customId: "ingredients",
|
||||
style: TextInputStyle.Paragraph,
|
||||
required: false,
|
||||
value: ingredientsStr,
|
||||
placeholder: 'iron_ingot:3, wood_plank:1'
|
||||
}
|
||||
placeholder: "iron_ingot:3, wood_plank:1",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
||||
await i.showModal(modal);
|
||||
try {
|
||||
const sub = await i.awaitModalSubmit({ time: 300_000 });
|
||||
const enabledStr = sub.components.getTextInputValue('enabled').trim().toLowerCase();
|
||||
const quantityStr = sub.components.getTextInputValue('quantity').trim();
|
||||
const ingredientsInput = sub.components.getTextInputValue('ingredients').trim();
|
||||
const enabledStr = sub.components
|
||||
.getTextInputValue("enabled")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const quantityStr = sub.components.getTextInputValue("quantity").trim();
|
||||
const ingredientsInput = sub.components
|
||||
.getTextInputValue("ingredients")
|
||||
.trim();
|
||||
|
||||
const enabled = enabledStr === 'true';
|
||||
const enabled = enabledStr === "true";
|
||||
const productQuantity = parseInt(quantityStr, 10) || 1;
|
||||
|
||||
|
||||
// Parsear ingredientes
|
||||
const ingredients: Array<{ itemKey: string; quantity: number }> = [];
|
||||
if (ingredientsInput && enabled) {
|
||||
const parts = ingredientsInput.split(',').map(p => p.trim()).filter(Boolean);
|
||||
const parts = ingredientsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
for (const part of parts) {
|
||||
const [itemKey, qtyStr] = part.split(':').map(s => s.trim());
|
||||
const [itemKey, qtyStr] = part.split(":").map((s) => s.trim());
|
||||
const qty = parseInt(qtyStr, 10);
|
||||
if (itemKey && qty > 0) {
|
||||
ingredients.push({ itemKey, quantity: qty });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
state.recipe = { enabled, ingredients, productQuantity };
|
||||
|
||||
await sub.deferUpdate();
|
||||
await editorMsg.edit({
|
||||
content: null,
|
||||
flags: 32768,
|
||||
components: buildComponents()
|
||||
components: buildComponents(),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,12 @@
|
||||
import { prisma } from '../../core/database/prisma';
|
||||
import type { ItemProps, InventoryState, Price, OpenChestResult } from './types';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { ensureUserAndGuildExist } from '../core/userService';
|
||||
import { prisma } from "../../core/database/prisma";
|
||||
import type {
|
||||
ItemProps,
|
||||
InventoryState,
|
||||
Price,
|
||||
OpenChestResult,
|
||||
} from "./types";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { ensureUserAndGuildExist } from "../core/userService";
|
||||
|
||||
// Utilidades de tiempo
|
||||
function now(): Date {
|
||||
@@ -24,7 +29,7 @@ export async function findItemByKey(guildId: string, key: string) {
|
||||
},
|
||||
orderBy: [
|
||||
// preferir coincidencia del servidor
|
||||
{ guildId: 'desc' },
|
||||
{ guildId: "desc" },
|
||||
],
|
||||
});
|
||||
return item;
|
||||
@@ -33,7 +38,7 @@ export async function findItemByKey(guildId: string, key: string) {
|
||||
export async function getOrCreateWallet(userId: string, guildId: string) {
|
||||
// Asegurar que User y Guild existan antes de crear/buscar wallet
|
||||
await ensureUserAndGuildExist(userId, guildId);
|
||||
|
||||
|
||||
return prisma.economyWallet.upsert({
|
||||
where: { userId_guildId: { userId, guildId } },
|
||||
update: {},
|
||||
@@ -41,7 +46,11 @@ export async function getOrCreateWallet(userId: string, guildId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function adjustCoins(userId: string, guildId: string, delta: number) {
|
||||
export async function adjustCoins(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
delta: number
|
||||
) {
|
||||
const wallet = await getOrCreateWallet(userId, guildId);
|
||||
const next = Math.max(0, wallet.coins + delta);
|
||||
return prisma.economyWallet.update({
|
||||
@@ -52,16 +61,28 @@ export async function adjustCoins(userId: string, guildId: string, delta: number
|
||||
|
||||
export type EnsureInventoryOptions = { createIfMissing?: boolean };
|
||||
|
||||
export async function getInventoryEntryByItemId(userId: string, guildId: string, itemId: string, opts?: EnsureInventoryOptions) {
|
||||
export async function getInventoryEntryByItemId(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
itemId: string,
|
||||
opts?: EnsureInventoryOptions
|
||||
) {
|
||||
const existing = await prisma.inventoryEntry.findUnique({
|
||||
where: { userId_guildId_itemId: { userId, guildId, itemId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
if (!opts?.createIfMissing) return null;
|
||||
return prisma.inventoryEntry.create({ data: { userId, guildId, itemId, quantity: 0 } });
|
||||
return prisma.inventoryEntry.create({
|
||||
data: { userId, guildId, itemId, quantity: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInventoryEntry(userId: string, guildId: string, itemKey: string, opts?: EnsureInventoryOptions) {
|
||||
export async function getInventoryEntry(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
itemKey: string,
|
||||
opts?: EnsureInventoryOptions
|
||||
) {
|
||||
const item = await findItemByKey(guildId, itemKey);
|
||||
if (!item) throw new Error(`Item key not found: ${itemKey}`);
|
||||
const entry = await getInventoryEntryByItemId(userId, guildId, item.id, opts);
|
||||
@@ -69,40 +90,57 @@ export async function getInventoryEntry(userId: string, guildId: string, itemKey
|
||||
}
|
||||
|
||||
function parseItemProps(json: unknown): ItemProps {
|
||||
if (!json || typeof json !== 'object') return {};
|
||||
if (!json || typeof json !== "object") return {};
|
||||
return json as ItemProps;
|
||||
}
|
||||
|
||||
function parseState(json: unknown): InventoryState {
|
||||
if (!json || typeof json !== 'object') return {};
|
||||
if (!json || typeof json !== "object") return {};
|
||||
return json as InventoryState;
|
||||
}
|
||||
|
||||
function checkUsableWindow(item: { usableFrom: Date | null; usableTo: Date | null; props: any }) {
|
||||
function checkUsableWindow(item: {
|
||||
usableFrom: Date | null;
|
||||
usableTo: Date | null;
|
||||
props: any;
|
||||
}) {
|
||||
const props = parseItemProps(item.props);
|
||||
const from = props.usableFrom ? new Date(props.usableFrom) : item.usableFrom;
|
||||
const to = props.usableTo ? new Date(props.usableTo) : item.usableTo;
|
||||
if (!isWithin(now(), from ?? null, to ?? null)) {
|
||||
throw new Error('Item no usable por ventana de tiempo');
|
||||
throw new Error("Item no usable por ventana de tiempo");
|
||||
}
|
||||
}
|
||||
|
||||
function checkAvailableWindow(item: { availableFrom: Date | null; availableTo: Date | null; props: any }) {
|
||||
function checkAvailableWindow(item: {
|
||||
availableFrom: Date | null;
|
||||
availableTo: Date | null;
|
||||
props: any;
|
||||
}) {
|
||||
const props = parseItemProps(item.props);
|
||||
const from = props.availableFrom ? new Date(props.availableFrom) : item.availableFrom;
|
||||
const from = props.availableFrom
|
||||
? new Date(props.availableFrom)
|
||||
: item.availableFrom;
|
||||
const to = props.availableTo ? new Date(props.availableTo) : item.availableTo;
|
||||
if (!isWithin(now(), from ?? null, to ?? null)) {
|
||||
throw new Error('Item no disponible para adquirir');
|
||||
throw new Error("Item no disponible para adquirir");
|
||||
}
|
||||
}
|
||||
|
||||
// Agrega cantidad respetando maxPerInventory y stackable
|
||||
export async function addItemByKey(userId: string, guildId: string, itemKey: string, qty: number) {
|
||||
export async function addItemByKey(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
itemKey: string,
|
||||
qty: number
|
||||
) {
|
||||
if (qty <= 0) return { added: 0 } as const;
|
||||
const found = await getInventoryEntry(userId, guildId, itemKey, { createIfMissing: true });
|
||||
const found = await getInventoryEntry(userId, guildId, itemKey, {
|
||||
createIfMissing: true,
|
||||
});
|
||||
const item = found.item;
|
||||
const entry = found.entry;
|
||||
if (!entry) throw new Error('No se pudo crear/obtener inventario');
|
||||
if (!entry) throw new Error("No se pudo crear/obtener inventario");
|
||||
checkAvailableWindow(item);
|
||||
|
||||
const max = item.maxPerInventory ?? Number.MAX_SAFE_INTEGER;
|
||||
@@ -119,17 +157,28 @@ export async function addItemByKey(userId: string, guildId: string, itemKey: str
|
||||
// No apilable: usar state.instances
|
||||
const state = parseState(entry.state);
|
||||
state.instances ??= [];
|
||||
const canAdd = Math.max(0, Math.min(qty, Math.max(0, max - state.instances.length)));
|
||||
const canAdd = Math.max(
|
||||
0,
|
||||
Math.min(qty, Math.max(0, max - state.instances.length))
|
||||
);
|
||||
for (let i = 0; i < canAdd; i++) state.instances.push({});
|
||||
const updated = await prisma.inventoryEntry.update({
|
||||
where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } },
|
||||
data: { state: state as unknown as Prisma.InputJsonValue, quantity: state.instances.length },
|
||||
data: {
|
||||
state: state as unknown as Prisma.InputJsonValue,
|
||||
quantity: state.instances.length,
|
||||
},
|
||||
});
|
||||
return { added: canAdd, entry: updated } as const;
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeItemByKey(userId: string, guildId: string, itemKey: string, qty: number) {
|
||||
export async function consumeItemByKey(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
itemKey: string,
|
||||
qty: number
|
||||
) {
|
||||
if (qty <= 0) return { consumed: 0 } as const;
|
||||
const { item, entry } = await getInventoryEntry(userId, guildId, itemKey);
|
||||
if (!entry || (entry.quantity ?? 0) <= 0) return { consumed: 0 } as const;
|
||||
@@ -150,35 +199,100 @@ export async function consumeItemByKey(userId: string, guildId: string, itemKey:
|
||||
const newState: InventoryState = { ...state, instances };
|
||||
const updated = await prisma.inventoryEntry.update({
|
||||
where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } },
|
||||
data: { state: newState as unknown as Prisma.InputJsonValue, quantity: instances.length },
|
||||
data: {
|
||||
state: newState as unknown as Prisma.InputJsonValue,
|
||||
quantity: instances.length,
|
||||
},
|
||||
});
|
||||
return { consumed, entry: updated } as const;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openChestByKey(userId: string, guildId: string, itemKey: string): Promise<OpenChestResult> {
|
||||
export async function openChestByKey(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
itemKey: string
|
||||
): Promise<OpenChestResult> {
|
||||
const { item, entry } = await getInventoryEntry(userId, guildId, itemKey);
|
||||
if (!entry || (entry.quantity ?? 0) <= 0) throw new Error('No tienes este cofre');
|
||||
if (!entry || (entry.quantity ?? 0) <= 0)
|
||||
throw new Error("No tienes este cofre");
|
||||
checkUsableWindow(item);
|
||||
|
||||
const props = parseItemProps(item.props);
|
||||
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 result: OpenChestResult = { coinsDelta: 0, itemsToAdd: [], rolesToGrant: [], consumed: false };
|
||||
const mode = chest.randomMode || "all";
|
||||
const result: OpenChestResult = {
|
||||
coinsDelta: 0,
|
||||
itemsToAdd: [],
|
||||
rolesToGrant: [],
|
||||
consumed: false,
|
||||
};
|
||||
|
||||
for (const r of rewards) {
|
||||
if (r.type === 'coins') result.coinsDelta += Math.max(0, r.amount);
|
||||
else if (r.type === 'item') result.itemsToAdd.push({ itemKey: r.itemKey, itemId: r.itemId, qty: r.qty });
|
||||
else if (r.type === 'role') result.rolesToGrant.push(r.roleId);
|
||||
function pickOneWeighted<T extends { probability?: number }>(
|
||||
arr: T[]
|
||||
): T | null {
|
||||
const prepared = arr.map((a) => ({
|
||||
...a,
|
||||
_w: a.probability != null ? Math.max(0, a.probability) : 1,
|
||||
}));
|
||||
const total = prepared.reduce((s, a) => s + a._w, 0);
|
||||
if (total <= 0) return null;
|
||||
let r = Math.random() * total;
|
||||
for (const a of prepared) {
|
||||
r -= a._w;
|
||||
if (r <= 0) return a;
|
||||
}
|
||||
return prepared[prepared.length - 1] ?? null;
|
||||
}
|
||||
|
||||
if (mode === "single") {
|
||||
const one = pickOneWeighted(rewards);
|
||||
if (one) {
|
||||
if (one.type === "coins") result.coinsDelta += Math.max(0, one.amount);
|
||||
else if (one.type === "item")
|
||||
result.itemsToAdd.push({
|
||||
itemKey: one.itemKey,
|
||||
itemId: one.itemId,
|
||||
qty: one.qty,
|
||||
});
|
||||
else if (one.type === "role") result.rolesToGrant.push(one.roleId);
|
||||
}
|
||||
} else {
|
||||
// 'all' y 'roll-each': procesar cada reward con probabilidad (default 100%)
|
||||
for (const r of rewards) {
|
||||
const p = r.probability != null ? Math.max(0, r.probability) : 1; // p en [0,1] recomendado; si usan valores >1 se interpretan como peso
|
||||
// Si p > 1 asumimos error o peso -> para modo 'all' lo tratamos como 1 (100%)
|
||||
const chance = p > 1 ? 1 : p; // normalizado
|
||||
if (Math.random() <= chance) {
|
||||
if (r.type === "coins") result.coinsDelta += Math.max(0, r.amount);
|
||||
else if (r.type === "item")
|
||||
result.itemsToAdd.push({
|
||||
itemKey: r.itemKey,
|
||||
itemId: r.itemId,
|
||||
qty: r.qty,
|
||||
});
|
||||
else if (r.type === "role") result.rolesToGrant.push(r.roleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Roles fijos adicionales en chest.roles
|
||||
if (Array.isArray(chest.roles) && chest.roles.length) {
|
||||
for (const roleId of chest.roles) {
|
||||
if (typeof roleId === "string" && roleId.length > 0)
|
||||
result.rolesToGrant.push(roleId);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.coinsDelta) await adjustCoins(userId, guildId, result.coinsDelta);
|
||||
for (const it of result.itemsToAdd) {
|
||||
if (it.itemKey) await addItemByKey(userId, guildId, it.itemKey, it.qty);
|
||||
else if (it.itemId) {
|
||||
const item = await prisma.economyItem.findUnique({ where: { id: it.itemId } });
|
||||
const item = await prisma.economyItem.findUnique({
|
||||
where: { id: it.itemId },
|
||||
});
|
||||
if (item) await addItemByKey(userId, guildId, item.key, it.qty);
|
||||
}
|
||||
}
|
||||
@@ -191,23 +305,29 @@ export async function openChestByKey(userId: string, guildId: string, itemKey: s
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function craftByProductKey(userId: string, guildId: string, productKey: string) {
|
||||
export async function craftByProductKey(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
productKey: string
|
||||
) {
|
||||
const product = await findItemByKey(guildId, productKey);
|
||||
if (!product) throw new Error(`Producto no encontrado: ${productKey}`);
|
||||
const recipe = await prisma.itemRecipe.findUnique({
|
||||
where: { productItemId: product.id },
|
||||
include: { ingredients: true },
|
||||
});
|
||||
if (!recipe) throw new Error('No existe receta para este ítem');
|
||||
if (!recipe) throw new Error("No existe receta para este ítem");
|
||||
|
||||
// Verificar ingredientes suficientes
|
||||
const shortages: string[] = [];
|
||||
for (const ing of recipe.ingredients) {
|
||||
const inv = await prisma.inventoryEntry.findUnique({ where: { userId_guildId_itemId: { userId, guildId, itemId: ing.itemId } } });
|
||||
const inv = await prisma.inventoryEntry.findUnique({
|
||||
where: { userId_guildId_itemId: { userId, guildId, itemId: ing.itemId } },
|
||||
});
|
||||
const have = inv?.quantity ?? 0;
|
||||
if (have < ing.quantity) shortages.push(ing.itemId);
|
||||
}
|
||||
if (shortages.length) throw new Error('Ingredientes insuficientes');
|
||||
if (shortages.length) throw new Error("Ingredientes insuficientes");
|
||||
|
||||
// Consumir ingredientes
|
||||
for (const ing of recipe.ingredients) {
|
||||
@@ -218,17 +338,29 @@ export async function craftByProductKey(userId: string, guildId: string, product
|
||||
}
|
||||
|
||||
// Agregar producto
|
||||
const add = await addItemByKey(userId, guildId, product.key, recipe.productQuantity);
|
||||
const add = await addItemByKey(
|
||||
userId,
|
||||
guildId,
|
||||
product.key,
|
||||
recipe.productQuantity
|
||||
);
|
||||
return { added: add.added, product } as const;
|
||||
}
|
||||
|
||||
export async function buyFromOffer(userId: string, guildId: string, offerId: string, qty = 1) {
|
||||
if (qty <= 0) throw new Error('Cantidad inválida');
|
||||
export async function buyFromOffer(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
offerId: string,
|
||||
qty = 1
|
||||
) {
|
||||
if (qty <= 0) throw new Error("Cantidad inválida");
|
||||
const offer = await prisma.shopOffer.findUnique({ where: { id: offerId } });
|
||||
if (!offer || offer.guildId !== guildId) throw new Error('Oferta no encontrada');
|
||||
if (!offer.enabled) throw new Error('Oferta deshabilitada');
|
||||
if (!offer || offer.guildId !== guildId)
|
||||
throw new Error("Oferta no encontrada");
|
||||
if (!offer.enabled) throw new Error("Oferta deshabilitada");
|
||||
const nowD = now();
|
||||
if (!isWithin(nowD, offer.startAt ?? null, offer.endAt ?? null)) throw new Error('Oferta fuera de fecha');
|
||||
if (!isWithin(nowD, offer.startAt ?? null, offer.endAt ?? null))
|
||||
throw new Error("Oferta fuera de fecha");
|
||||
|
||||
const price = (offer.price as unknown as Price) ?? {};
|
||||
// Limites
|
||||
@@ -238,19 +370,23 @@ export async function buyFromOffer(userId: string, guildId: string, offerId: str
|
||||
_sum: { qty: true },
|
||||
});
|
||||
const already = count._sum.qty ?? 0;
|
||||
if (already + qty > offer.perUserLimit) throw new Error('Excede el límite por usuario');
|
||||
if (already + qty > offer.perUserLimit)
|
||||
throw new Error("Excede el límite por usuario");
|
||||
}
|
||||
|
||||
if (offer.stock != null) {
|
||||
if (offer.stock < qty) throw new Error('Stock insuficiente');
|
||||
if (offer.stock < qty) throw new Error("Stock insuficiente");
|
||||
}
|
||||
|
||||
// Cobro: coins
|
||||
if (price.coins && price.coins > 0) {
|
||||
const wallet = await getOrCreateWallet(userId, guildId);
|
||||
const total = price.coins * qty;
|
||||
if (wallet.coins < total) throw new Error('Monedas insuficientes');
|
||||
await prisma.economyWallet.update({ where: { userId_guildId: { userId, guildId } }, data: { coins: wallet.coins - total } });
|
||||
if (wallet.coins < total) throw new Error("Monedas insuficientes");
|
||||
await prisma.economyWallet.update({
|
||||
where: { userId_guildId: { userId, guildId } },
|
||||
data: { coins: wallet.coins - total },
|
||||
});
|
||||
}
|
||||
// Cobro: items
|
||||
if (price.items && price.items.length) {
|
||||
@@ -265,9 +401,12 @@ export async function buyFromOffer(userId: string, guildId: string, offerId: str
|
||||
} else if (comp.itemId) {
|
||||
itemId = comp.itemId;
|
||||
}
|
||||
if (!itemId) throw new Error('Item de precio inválido');
|
||||
const inv = await prisma.inventoryEntry.findUnique({ where: { userId_guildId_itemId: { userId, guildId, itemId } } });
|
||||
if ((inv?.quantity ?? 0) < compQty) throw new Error('No tienes suficientes items para pagar');
|
||||
if (!itemId) throw new Error("Item de precio inválido");
|
||||
const inv = await prisma.inventoryEntry.findUnique({
|
||||
where: { userId_guildId_itemId: { userId, guildId, itemId } },
|
||||
});
|
||||
if ((inv?.quantity ?? 0) < compQty)
|
||||
throw new Error("No tienes suficientes items para pagar");
|
||||
}
|
||||
// si todo está ok, descontar
|
||||
for (const comp of price.items) {
|
||||
@@ -281,21 +420,31 @@ export async function buyFromOffer(userId: string, guildId: string, offerId: str
|
||||
itemId = comp.itemId;
|
||||
}
|
||||
if (!itemId) continue;
|
||||
await prisma.inventoryEntry.update({ where: { userId_guildId_itemId: { userId, guildId, itemId } }, data: { quantity: { decrement: compQty } } });
|
||||
await prisma.inventoryEntry.update({
|
||||
where: { userId_guildId_itemId: { userId, guildId, itemId } },
|
||||
data: { quantity: { decrement: compQty } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Entregar producto
|
||||
const item = await prisma.economyItem.findUnique({ where: { id: offer.itemId } });
|
||||
if (!item) throw new Error('Ítem de oferta no existente');
|
||||
const item = await prisma.economyItem.findUnique({
|
||||
where: { id: offer.itemId },
|
||||
});
|
||||
if (!item) throw new Error("Ítem de oferta no existente");
|
||||
await addItemByKey(userId, guildId, item.key, qty);
|
||||
|
||||
// Registrar compra
|
||||
await prisma.shopPurchase.create({ data: { offerId: offer.id, userId, guildId, qty } });
|
||||
await prisma.shopPurchase.create({
|
||||
data: { offerId: offer.id, userId, guildId, qty },
|
||||
});
|
||||
|
||||
// Reducir stock global
|
||||
if (offer.stock != null) {
|
||||
await prisma.shopOffer.update({ where: { id: offer.id }, data: { stock: offer.stock - qty } });
|
||||
await prisma.shopOffer.update({
|
||||
where: { id: offer.id },
|
||||
data: { stock: offer.stock - qty },
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, item, qty } as const;
|
||||
@@ -307,24 +456,35 @@ export async function buyFromOffer(userId: string, guildId: string, offerId: str
|
||||
export async function findMutationByKey(guildId: string, key: string) {
|
||||
return prisma.itemMutation.findFirst({
|
||||
where: { key, OR: [{ guildId }, { guildId: null }] },
|
||||
orderBy: [{ guildId: 'desc' }],
|
||||
orderBy: [{ guildId: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyMutationToInventory(userId: string, guildId: string, itemKey: string, mutationKey: string) {
|
||||
const { item, entry } = await getInventoryEntry(userId, guildId, itemKey, { createIfMissing: true });
|
||||
if (!entry) throw new Error('Inventario inexistente');
|
||||
export async function applyMutationToInventory(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
itemKey: string,
|
||||
mutationKey: string
|
||||
) {
|
||||
const { item, entry } = await getInventoryEntry(userId, guildId, itemKey, {
|
||||
createIfMissing: true,
|
||||
});
|
||||
if (!entry) throw new Error("Inventario inexistente");
|
||||
|
||||
// Política de mutaciones
|
||||
const props = parseItemProps(item.props);
|
||||
const policy = props.mutationPolicy;
|
||||
if (policy?.deniedKeys?.includes(mutationKey)) throw new Error('Mutación denegada');
|
||||
if (policy?.allowedKeys && !policy.allowedKeys.includes(mutationKey)) throw new Error('Mutación no permitida');
|
||||
if (policy?.deniedKeys?.includes(mutationKey))
|
||||
throw new Error("Mutación denegada");
|
||||
if (policy?.allowedKeys && !policy.allowedKeys.includes(mutationKey))
|
||||
throw new Error("Mutación no permitida");
|
||||
|
||||
const mutation = await findMutationByKey(guildId, mutationKey);
|
||||
if (!mutation) throw new Error('Mutación no encontrada');
|
||||
if (!mutation) throw new Error("Mutación no encontrada");
|
||||
|
||||
// Registrar vínculo
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
export type PriceItemComponent = {
|
||||
itemKey?: string; // preferido para lookup
|
||||
itemId?: string; // fallback directo
|
||||
itemId?: string; // fallback directo
|
||||
qty: number;
|
||||
};
|
||||
|
||||
@@ -14,9 +14,15 @@ export type Price = {
|
||||
};
|
||||
|
||||
export type ChestReward =
|
||||
| { type: 'coins'; amount: number }
|
||||
| { type: 'item'; itemKey?: string; itemId?: string; qty: number }
|
||||
| { type: 'role'; roleId: string };
|
||||
| { type: "coins"; amount: number; probability?: number }
|
||||
| {
|
||||
type: "item";
|
||||
itemKey?: string;
|
||||
itemId?: string;
|
||||
qty: number;
|
||||
probability?: number;
|
||||
}
|
||||
| { type: "role"; roleId: string; probability?: number };
|
||||
|
||||
export type PassiveEffect = {
|
||||
key: string; // p.ej. "xpBoost", "defenseUp"
|
||||
@@ -38,8 +44,15 @@ export type CraftableProps = {
|
||||
|
||||
export type ChestProps = {
|
||||
enabled?: boolean;
|
||||
// Recompensas que el bot debe otorgar al "abrir"
|
||||
// Modo de randomización:
|
||||
// 'all' (default): se procesan todas las recompensas y cada una evalúa su probability (si no hay probability, se asume 100%).
|
||||
// 'single': selecciona UNA recompensa aleatoria ponderada por probability (o 1 si falta) y solo otorga esa.
|
||||
// 'roll-each': similar a 'all' pero probability se trata como chance independiente (igual que all; se mantiene por semántica futura).
|
||||
randomMode?: "all" | "single" | "roll-each";
|
||||
// Recompensas configuradas
|
||||
rewards?: ChestReward[];
|
||||
// Roles adicionales fijos (independientes de rewards)
|
||||
roles?: string[];
|
||||
// Si true, consume 1 del inventario al abrir
|
||||
consumeOnOpen?: boolean;
|
||||
};
|
||||
@@ -60,7 +73,7 @@ export type ShopProps = {
|
||||
};
|
||||
|
||||
export type ToolProps = {
|
||||
type: 'pickaxe' | 'rod' | 'sword' | 'bow' | 'halberd' | 'net' | string; // extensible
|
||||
type: "pickaxe" | "rod" | "sword" | "bow" | "halberd" | "net" | string; // extensible
|
||||
tier?: number; // nivel/calidad de la herramienta
|
||||
};
|
||||
|
||||
@@ -76,6 +89,8 @@ export type ItemProps = {
|
||||
breakable?: BreakableProps; // romperse
|
||||
craftable?: CraftableProps; // craftear
|
||||
chest?: ChestProps; // estilo cofre que al usar da roles/ítems/monedas
|
||||
// Si true, este ítem se considera global (guildId = null) y solo el owner del bot puede editarlo
|
||||
global?: boolean;
|
||||
eventCurrency?: EventCurrencyProps; // puede actuar como moneda de evento
|
||||
passiveEffects?: PassiveEffect[]; // efectos por tenerlo
|
||||
mutationPolicy?: MutationPolicy; // reglas para mutaciones extra
|
||||
|
||||
Reference in New Issue
Block a user