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:
2025-10-08 23:34:55 -05:00
parent 03f66f2f82
commit 44aad43df6
4 changed files with 1246 additions and 531 deletions

View File

@@ -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 ? '' : '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 ? "" : "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

View File

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

View File

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