diff --git a/src/commands/messages/game/itemCreate.ts b/src/commands/messages/game/itemCreate.ts index 43aaf61..7198a2d 100644 --- a/src/commands/messages/game/itemCreate.ts +++ b/src/commands/messages/game/itemCreate.ts @@ -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 ', + description: + "Crea un EconomyItem para este servidor con un pequeño editor interactivo.", + category: "Economía", + usage: "item-crear ", 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 `' - }] - }], - reply: { messageReference: message.id } + components: [ + { + type: 17, + accent_color: 0xffa500, + components: [ + { + type: 10, + content: + "⚠️ **Uso Incorrecto**\n└ Uso: `!item-crear `", + }, + ], + }, + ], + 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 {} } diff --git a/src/commands/messages/game/itemEdit.ts b/src/commands/messages/game/itemEdit.ts index 33de07f..2492a0d 100644 --- a/src/commands/messages/game/itemEdit.ts +++ b/src/commands/messages/game/itemEdit.ts @@ -1,10 +1,20 @@ -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 { promptKeySelection, resolveItemIcon } from './_helpers'; +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 { promptKeySelection, resolveItemIcon } from "./_helpers"; interface ItemEditorState { key: string; @@ -22,54 +32,69 @@ interface ItemEditorState { ingredients: Array<{ itemKey: string; quantity: number }>; productQuantity: number; }; + isGlobal?: boolean; } export const command: CommandMessage = { - name: 'item-editar', - type: 'message', - aliases: ['editar-item','itemedit'], + name: "item-editar", + type: "message", + aliases: ["editar-item", "itemedit"], cooldown: 10, - description: 'Edita un EconomyItem existente del servidor con un pequeño editor interactivo.', - category: 'Economía', - usage: 'item-editar', + description: + "Edita un EconomyItem existente del servidor con un pequeño editor interactivo.", + category: "Economía", + usage: "item-editar", 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; } const guildId = message.guild!.id; - const items = await client.prisma.economyItem.findMany({ where: { guildId }, orderBy: [{ key: 'asc' }] }); + const items = await client.prisma.economyItem.findMany({ + where: { guildId }, + orderBy: [{ key: "asc" }], + }); const selection = await promptKeySelection(message, { entries: items, - customIdPrefix: 'item_edit', - title: 'Selecciona un ítem para editar', - emptyText: '⚠️ **No hay ítems locales configurados.** Usa `!item-crear` primero.', - placeholder: 'Elige un ítem…', - filterHint: 'Filtra por nombre, key, categoría o tag.', + customIdPrefix: "item_edit", + title: "Selecciona un ítem para editar", + emptyText: + "⚠️ **No hay ítems locales configurados.** Usa `!item-crear` primero.", + placeholder: "Elige un ítem…", + filterHint: "Filtra por nombre, key, categoría o tag.", getOption: (item) => { const icon = resolveItemIcon(item.icon); - const label = `${icon} ${(item.name ?? item.key)}`.trim(); + const label = `${icon} ${item.name ?? item.key}`.trim(); const tags = Array.isArray(item.tags) ? item.tags : []; return { value: item.id, label: label.slice(0, 100), description: item.key, - keywords: [item.key, item.name ?? '', item.category ?? '', ...tags], + keywords: [item.key, item.name ?? "", item.category ?? "", ...tags], }; }, }); @@ -81,17 +106,17 @@ export const command: CommandMessage = { const existing = selection.entry; // Cargar receta si existe - let existingRecipe: { - ingredients: Array<{ item: { key: string }; quantity: number }>; + let existingRecipe: { + ingredients: Array<{ item: { key: string }; quantity: number }>; productQuantity: number; } | null = null; try { existingRecipe = await client.prisma.itemRecipe.findUnique({ where: { productItemId: existing.id }, - include: { ingredients: { include: { item: true } } } + include: { ingredients: { include: { item: true } } }, }); } catch (e) { - logger.warn({ err: e }, 'Error cargando receta existente'); + logger.warn({ err: e }, "Error cargando receta existente"); } const state: ItemEditorState = { @@ -104,65 +129,71 @@ export const command: CommandMessage = { maxPerInventory: existing.maxPerInventory ?? null, tags: Array.isArray(existing.tags) ? existing.tags : [], props: existing.props || {}, - recipe: existingRecipe ? { - enabled: true, - ingredients: existingRecipe.ingredients.map(ing => ({ - itemKey: ing.item.key, - quantity: ing.quantity - })), - productQuantity: existingRecipe.productQuantity - } : { - enabled: false, - ingredients: [], - productQuantity: 1 - } + recipe: existingRecipe + ? { + enabled: true, + ingredients: existingRecipe.ingredients.map((ing) => ({ + itemKey: ing.item.key, + quantity: ing.quantity, + })), + productQuantity: existingRecipe.productQuantity, + } + : { + enabled: false, + ingredients: [], + productQuantity: 1, + }, + isGlobal: !!(existing.props as any)?.global || existing.guildId === null, }; 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: `# 🛠️ Editando Item: \`${state.key}\`` + content: `# 🛠️ Editando Item: \`${state.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\`\`\``, + }, + ], }; }; @@ -171,19 +202,49 @@ 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 = selection.panelMessage; @@ -193,50 +254,100 @@ export const command: CommandMessage = { components: buildEditorComponents(), }); - 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, guildId, existing.id); + if (i.customId === "it_recipe") { + await showRecipeModal( + i as ButtonInteraction, + state, + editorMsg, + buildEditorComponents, + client, + guildId, + existing.id + ); 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; } + // Revalidar global flag (en caso de editar props JSON) + state.isGlobal = + !!(state.props as any)?.global || existing.guildId === null; + const BOT_OWNER_ID = "327207082203938818"; + if (state.isGlobal && i.user.id !== BOT_OWNER_ID) { + await i.reply({ + content: + "❌ No puedes editar un ítem global. Solo el owner del bot.", + flags: MessageFlags.Ephemeral, + }); + return; + } + // No permitir convertir un item local en global mediante edición si no es owner + if (!existing.guildId && !state.isGlobal) { + // Prevent accidental removal of global status + (state.props as any).global = true; + } // Actualizar await client.prisma.economyItem.update({ where: { id: existing.id }, @@ -251,39 +362,43 @@ export const command: CommandMessage = { props: state.props ?? {}, }, }); - + // Actualizar/crear/eliminar receta try { - const existingRecipeCheck = await client.prisma.itemRecipe.findUnique({ - where: { productItemId: existing.id }, - include: { ingredients: true } - }); - + const existingRecipeCheck = + await client.prisma.itemRecipe.findUnique({ + where: { productItemId: existing.id }, + include: { ingredients: true }, + }); + if (state.recipe?.enabled && state.recipe.ingredients.length > 0) { // 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, }); } - + if (existingRecipeCheck) { // Actualizar receta existente // Primero eliminar ingredientes viejos await client.prisma.recipeIngredient.deleteMany({ - where: { recipeId: existingRecipeCheck.id } + where: { recipeId: existingRecipeCheck.id }, }); // Luego actualizar la receta con los nuevos ingredientes await client.prisma.itemRecipe.update({ @@ -291,9 +406,9 @@ export const command: CommandMessage = { data: { productQuantity: state.recipe.productQuantity, ingredients: { - create: ingredientsData - } - } + create: ingredientsData, + }, + }, }); } else { // Crear nueva receta @@ -302,87 +417,170 @@ export const command: CommandMessage = { productItemId: existing.id, productQuantity: state.recipe.productQuantity, ingredients: { - create: ingredientsData - } - } + create: ingredientsData, + }, + }, }); } } else if (existingRecipeCheck && !state.recipe?.enabled) { // Eliminar receta si está deshabilitada await client.prisma.recipeIngredient.deleteMany({ - where: { recipeId: existingRecipeCheck.id } + where: { recipeId: existingRecipeCheck.id }, }); await client.prisma.itemRecipe.delete({ - where: { id: existingRecipeCheck.id } + where: { id: existingRecipeCheck.id }, }); } } catch (err: any) { - logger.warn({ err }, 'Error actualizando receta'); - await i.followUp({ content: `⚠️ Item actualizado pero falló la receta: ${err.message}`, flags: MessageFlags.Ephemeral }); + logger.warn({ err }, "Error actualizando receta"); + await i.followUp({ + content: `⚠️ Item actualizado pero falló la receta: ${err.message}`, + flags: MessageFlags.Ephemeral, + }); } - - await i.reply({ content: '✅ Item actualizado!', flags: MessageFlags.Ephemeral }); - await editorMsg.edit({ + + await i.reply({ + content: "✅ Item actualizado!", + flags: MessageFlags.Ephemeral, + }); + await editorMsg.edit({ content: null, flags: 32768, - components: [{ - type: 17, - accent_color: 0x00FF00, - components: [{ - type: 10, - content: `✅ **Item Actualizado**\n└ Item \`${state.key}\` actualizado exitosamente.` - }] - }] + components: [ + { + type: 17, + accent_color: 0x00ff00, + components: [ + { + type: 10, + content: `✅ **Item Actualizado**\n└ Item \`${ + state.key + }\` actualizado exitosamente.${ + state.isGlobal ? " (Global)" : "" + }`, + }, + ], + }, + ], }); - collector.stop('saved'); + collector.stop("saved"); return; } } catch (err) { - logger.error({ err }, 'item-editar interaction error'); - if (!i.deferred && !i.replied) await i.reply({ content: '❌ Error procesando la acción.', flags: MessageFlags.Ephemeral }); + logger.error({ err }, "item-editar 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; @@ -390,8 +588,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; } @@ -400,162 +598,228 @@ 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: Message, buildComponents: () => any[], client: Amayo, guildId: string, itemId: string) { - 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: Message, + buildComponents: () => any[], + client: Amayo, + guildId: string, + itemId: string +) { + 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 {} } diff --git a/src/game/economy/service.ts b/src/game/economy/service.ts index 305d876..3e607b0 100644 --- a/src/game/economy/service.ts +++ b/src/game/economy/service.ts @@ -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 { +export async function openChestByKey( + userId: string, + guildId: string, + itemKey: string +): Promise { 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( + 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; } diff --git a/src/game/economy/types.ts b/src/game/economy/types.ts index c2bdfaa..5f3e422 100644 --- a/src/game/economy/types.ts +++ b/src/game/economy/types.ts @@ -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