diff --git a/src/commands/messages/game/abrir.ts b/src/commands/messages/game/abrir.ts new file mode 100644 index 0000000..9e9767a --- /dev/null +++ b/src/commands/messages/game/abrir.ts @@ -0,0 +1,39 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { openChestByKey } from '../../../game/economy/service'; + +export const command: CommandMessage = { + name: 'abrir', + type: 'message', + aliases: ['open'], + cooldown: 3, + description: 'Abre un cofre (item) por key y recibe sus recompensas/roles.', + usage: 'abrir ', + run: async (message, args, _client: Amayo) => { + const itemKey = args[0]?.trim(); + if (!itemKey) { await message.reply('Uso: `!abrir `'); return; } + try { + const res = await openChestByKey(message.author.id, message.guild!.id, itemKey); + const coins = res.coinsDelta ? `🪙 +${res.coinsDelta}` : ''; + const items = res.itemsToAdd.length ? res.itemsToAdd.map(i => `${i.itemKey ?? i.itemId} x${i.qty}`).join(' · ') : ''; + let rolesGiven: string[] = []; + let rolesFailed: string[] = []; + if (res.rolesToGrant.length && message.member) { + for (const r of res.rolesToGrant) { + try { await message.member.roles.add(r); rolesGiven.push(r); } catch { rolesFailed.push(r); } + } + } + const lines = [ + `🎁 Abriste ${itemKey}${res.consumed ? ' (consumido 1)' : ''}`, + coins && `Monedas: ${coins}`, + items && `Ítems: ${items}`, + rolesGiven.length ? `Roles otorgados: ${rolesGiven.map(id=>`<@&${id}>`).join(', ')}` : '', + rolesFailed.length ? `Roles fallidos: ${rolesFailed.join(', ')}` : '', + ].filter(Boolean); + await message.reply(lines.join('\n')); + } catch (e: any) { + await message.reply(`❌ No se pudo abrir ${itemKey}: ${e?.message ?? e}`); + } + } +}; + diff --git a/src/commands/messages/game/comer.ts b/src/commands/messages/game/comer.ts new file mode 100644 index 0000000..3383e9d --- /dev/null +++ b/src/commands/messages/game/comer.ts @@ -0,0 +1,23 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { useConsumableByKey } from '../../../game/consumables/service'; + +export const command: CommandMessage = { + name: 'comer', + type: 'message', + aliases: ['usar-comida','usar'], + cooldown: 3, + description: 'Usa un ítem consumible (comida/poción) para curarte. Respeta cooldowns.', + usage: 'comer ', + run: async (message, args, _client: Amayo) => { + const itemKey = args[0]?.trim(); + if (!itemKey) { await message.reply('Uso: `!comer `'); return; } + try { + const res = await useConsumableByKey(message.author.id, message.guild!.id, itemKey); + await message.reply(`🍽️ Usaste ${itemKey}. Curado: +${res.healed} HP.`); + } catch (e: any) { + await message.reply(`❌ No se pudo usar ${itemKey}: ${e?.message ?? e}`); + } + } +}; + diff --git a/src/commands/messages/game/comprar.ts b/src/commands/messages/game/comprar.ts new file mode 100644 index 0000000..99ca694 --- /dev/null +++ b/src/commands/messages/game/comprar.ts @@ -0,0 +1,24 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { buyFromOffer } from '../../../game/economy/service'; + +export const command: CommandMessage = { + name: 'comprar', + type: 'message', + aliases: ['buy'], + cooldown: 3, + description: 'Compra una oferta de la tienda por su ID. Respeta límites y stock.', + usage: 'comprar [qty] (ej: comprar off_123 2)', + run: async (message, args, _client: Amayo) => { + const offerId = args[0]?.trim(); + const qty = Math.max(1, parseInt(args[1] || '1', 10) || 1); + if (!offerId) { await message.reply('Uso: `!comprar [qty]`'); return; } + try { + const res = await buyFromOffer(message.author.id, message.guild!.id, offerId, qty); + await message.reply(`🛒 Comprado: ${res.item.key} x${res.qty}`); + } catch (e: any) { + await message.reply(`❌ No se pudo comprar: ${e?.message ?? e}`); + } + } +}; + diff --git a/src/commands/messages/game/craftear.ts b/src/commands/messages/game/craftear.ts new file mode 100644 index 0000000..8cd3c9c --- /dev/null +++ b/src/commands/messages/game/craftear.ts @@ -0,0 +1,35 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { craftByProductKey } from '../../../game/economy/service'; + +export const command: CommandMessage = { + name: 'craftear', + type: 'message', + aliases: ['craft'], + cooldown: 3, + description: 'Craftea un ítem por su productKey, consumiendo ingredientes según la receta.', + usage: 'craftear [veces] (ej: craftear ingot.iron 3)', + run: async (message, args, _client: Amayo) => { + const productKey = args[0]?.trim(); + const times = Math.max(1, parseInt(args[1] || '1', 10) || 1); + if (!productKey) { await message.reply('Uso: `!craftear [veces]`'); return; } + + let crafted = 0; + let lastError: any = null; + for (let i = 0; i < times; i++) { + try { + const res = await craftByProductKey(message.author.id, message.guild!.id, productKey); + crafted += res.added; + } catch (e: any) { + lastError = e; break; + } + } + + if (crafted > 0) { + await message.reply(`🛠️ Crafteado ${productKey} x${crafted * 1}.`); + } else { + await message.reply(`❌ No se pudo craftear: ${lastError?.message ?? 'revise ingredientes/receta'}`); + } + } +}; + diff --git a/src/commands/messages/game/encantar.ts b/src/commands/messages/game/encantar.ts new file mode 100644 index 0000000..5babb28 --- /dev/null +++ b/src/commands/messages/game/encantar.ts @@ -0,0 +1,24 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { applyMutationToInventory } from '../../../game/mutations/service'; + +export const command: CommandMessage = { + name: 'encantar', + type: 'message', + aliases: ['mutar','enchant'], + cooldown: 3, + description: 'Aplica una mutación/encantamiento a un ítem por su itemKey y mutationKey, respetando mutationPolicy.', + usage: 'encantar ', + run: async (message, args, _client: Amayo) => { + const itemKey = args[0]?.trim(); + const mutationKey = args[1]?.trim(); + if (!itemKey || !mutationKey) { await message.reply('Uso: `!encantar `'); return; } + try { + await applyMutationToInventory(message.author.id, message.guild!.id, itemKey, mutationKey); + await message.reply(`✨ Aplicada mutación ${mutationKey} a ${itemKey}.`); + } catch (e: any) { + await message.reply(`❌ No se pudo encantar: ${e?.message ?? e}`); + } + } +}; + diff --git a/src/commands/messages/game/equipar.ts b/src/commands/messages/game/equipar.ts new file mode 100644 index 0000000..2c036f4 --- /dev/null +++ b/src/commands/messages/game/equipar.ts @@ -0,0 +1,32 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { setEquipmentSlot } from '../../../game/combat/equipmentService'; +import { prisma } from '../../../core/database/prisma'; + +export const command: CommandMessage = { + name: 'equipar', + type: 'message', + aliases: ['equip'], + cooldown: 3, + description: 'Equipa un item en un slot (weapon|armor|cape) por su key, si lo tienes en inventario.', + usage: 'equipar ', + run: async (message, args, _client: Amayo) => { + const slot = (args[0]?.trim()?.toLowerCase() as 'weapon'|'armor'|'cape'|undefined); + const itemKey = args[1]?.trim(); + if (!slot || !['weapon','armor','cape'].includes(slot) || !itemKey) { + await message.reply('Uso: `!equipar `'); + return; + } + const guildId = message.guild!.id; + const userId = message.author.id; + + const item = await prisma.economyItem.findFirst({ where: { key: itemKey, OR: [{ guildId }, { guildId: null }] }, orderBy: [{ guildId: 'desc' }] }); + if (!item) { await message.reply('❌ Item no encontrado.'); return; } + const inv = await prisma.inventoryEntry.findUnique({ where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } } }); + if (!inv || inv.quantity <= 0) { await message.reply('❌ No tienes este item en tu inventario.'); return; } + + await setEquipmentSlot(userId, guildId, slot, item.id); + await message.reply(`🧰 Equipado en ${slot}: ${item.key}`); + } +}; + diff --git a/src/commands/messages/game/fundir.ts b/src/commands/messages/game/fundir.ts new file mode 100644 index 0000000..11c436d --- /dev/null +++ b/src/commands/messages/game/fundir.ts @@ -0,0 +1,40 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { createSmeltJob } from '../../../game/smelting/service'; + +export const command: CommandMessage = { + name: 'fundir', + type: 'message', + aliases: ['smelt'], + cooldown: 5, + description: 'Crea un job de fundición: descuenta insumos y estará listo tras el tiempo indicado.', + usage: 'fundir : [inputKey2>: ...]' + + '\nEj: fundir ingot.iron 1 60 ore.iron:3', + run: async (message, args, _client: Amayo) => { + const [outputKey, qtyStr, secsStr, ...rest] = args; + if (!outputKey || !qtyStr || !secsStr || rest.length === 0) { + await message.reply('Uso: `!fundir : [...]`'); + return; + } + const outputQty = parseInt(qtyStr, 10); + const seconds = parseInt(secsStr, 10); + if (!Number.isFinite(outputQty) || outputQty <= 0 || !Number.isFinite(seconds) || seconds <= 0) { + await message.reply('❌ Cantidades/segundos inválidos.'); + return; + } + const inputs = rest.map((tok) => { + const [k, q] = tok.split(':'); + return { itemKey: (k || '').trim(), qty: Math.max(1, parseInt((q || '1'), 10) || 1) }; + }).filter(x => x.itemKey); + if (!inputs.length) { await message.reply('❌ Debes especificar al menos un insumo como key:qty'); return; } + + try { + const res = await createSmeltJob(message.author.id, message.guild!.id, inputs, outputKey, outputQty, seconds); + const when = new Date(res.readyAt).toLocaleTimeString('es-ES', { hour12: false }); + await message.reply(`🔥 Fundición creada (job: ${res.jobId}). Estará lista a las ${when}.`); + } catch (e: any) { + await message.reply(`❌ No se pudo crear la fundición: ${e?.message ?? e}`); + } + } +}; + diff --git a/src/commands/messages/game/fundirReclamar.ts b/src/commands/messages/game/fundirReclamar.ts new file mode 100644 index 0000000..4ce0d81 --- /dev/null +++ b/src/commands/messages/game/fundirReclamar.ts @@ -0,0 +1,28 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { claimSmeltJob, claimNextReadyJob } from '../../../game/smelting/service'; + +export const command: CommandMessage = { + name: 'fundir-reclamar', + type: 'message', + aliases: ['smelt-claim','reclamar-fundicion'], + cooldown: 3, + description: 'Reclama una fundición lista por jobId o la más antigua lista si no especificas id.', + usage: 'fundir-reclamar [jobId]' + + '\nSin argumentos intenta reclamar la más antigua lista.', + run: async (message, args, _client: Amayo) => { + const jobId = args[0]?.trim(); + try { + if (jobId) { + await claimSmeltJob(message.author.id, message.guild!.id, jobId); + await message.reply(`✅ Fundición reclamada (job ${jobId}).`); + } else { + const res = await claimNextReadyJob(message.author.id, message.guild!.id); + await message.reply(`✅ Fundición reclamada (job ${res.jobId}).`); + } + } catch (e: any) { + await message.reply(`❌ No se pudo reclamar: ${e?.message ?? e}`); + } + } +}; + diff --git a/src/commands/messages/game/mobEdit.ts b/src/commands/messages/game/mobEdit.ts index 94a4163..5f3e54e 100644 --- a/src/commands/messages/game/mobEdit.ts +++ b/src/commands/messages/game/mobEdit.ts @@ -41,7 +41,6 @@ export const command: CommandMessage = { const editorMsg = await message.channel.send({ content: `👾 Editor de Mob (editar): \`${key}\``, - flags: MessageFlags.IsComponentsV2, components: [ { type: 1, components: [ { type: 2, style: ButtonStyle.Primary, label: 'Base', custom_id: 'mb_base' }, { type: 2, style: ButtonStyle.Secondary, label: 'Stats (JSON)', custom_id: 'mb_stats' }, @@ -98,4 +97,3 @@ async function showJsonModal(i: ButtonInteraction, state: MobEditorState, field: else { state[field] = field==='stats' ? { attack: 5 } : {}; await sub.reply({ content: 'ℹ️ Limpio.', flags: MessageFlags.Ephemeral }); } } catch {} } - diff --git a/src/commands/messages/game/monedas.ts b/src/commands/messages/game/monedas.ts new file mode 100644 index 0000000..c89b3c6 --- /dev/null +++ b/src/commands/messages/game/monedas.ts @@ -0,0 +1,17 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { getOrCreateWallet } from '../../../game/economy/service'; + +export const command: CommandMessage = { + name: 'monedas', + type: 'message', + aliases: ['coins','saldo'], + cooldown: 2, + description: 'Muestra tu saldo de monedas en este servidor.', + usage: 'monedas', + run: async (message, _args, _client: Amayo) => { + const wallet = await getOrCreateWallet(message.author.id, message.guild!.id); + await message.reply(`💰 Monedas: ${wallet.coins}`); + } +}; + diff --git a/src/game/minigames/service.ts b/src/game/minigames/service.ts index b4ad697..48835a8 100644 --- a/src/game/minigames/service.ts +++ b/src/game/minigames/service.ts @@ -4,6 +4,22 @@ import type { ItemProps, InventoryState } from '../economy/types'; import type { LevelRequirements, RunMinigameOptions, RunResult, RewardsTable, MobsTable } from './types'; import type { Prisma } from '@prisma/client'; +// Auto-select best tool from inventory by type and constraints +async function findBestToolKey(userId: string, guildId: string, toolType: string, opts?: { minTier?: number; allowedKeys?: string[] }) { + const entries = await prisma.inventoryEntry.findMany({ where: { userId, guildId, quantity: { gt: 0 } }, include: { item: true } }); + let best: { key: string; tier: number } | null = null; + for (const e of entries) { + const props = parseItemProps(e.item.props); + const t = props.tool; + if (!t || t.type !== toolType) continue; + const tier = Math.max(0, t.tier ?? 0); + if (opts?.minTier != null && tier < opts.minTier) continue; + if (opts?.allowedKeys && opts.allowedKeys.length && !opts.allowedKeys.includes(e.item.key)) continue; + if (!best || tier > best.tier) best = { key: e.item.key, tier }; + } + return best?.key ?? null; +} + function parseJSON(v: unknown): T | null { if (!v || (typeof v !== 'object' && typeof v !== 'string')) return null; return v as T; @@ -44,23 +60,30 @@ async function validateRequirements(userId: string, guildId: string, req?: Level const toolReq = req.tool; if (!toolReq) return { toolKeyUsed: undefined as string | undefined }; + let toolKeyUsed = toolKey; + + // Auto-select tool when required and not provided + if (!toolKeyUsed && toolReq.required && toolReq.toolType) { + toolKeyUsed = await findBestToolKey(userId, guildId, toolReq.toolType, { minTier: toolReq.minTier, allowedKeys: toolReq.allowedKeys }); + } + // herramienta requerida - if (toolReq.required && !toolKey) throw new Error('Se requiere una herramienta'); - if (!toolKey) return { toolKeyUsed: undefined }; + if (toolReq.required && !toolKeyUsed) throw new Error('Se requiere una herramienta adecuada'); + if (!toolKeyUsed) return { toolKeyUsed: undefined }; // verificar herramienta - const toolItem = await findItemByKey(guildId, toolKey); + const toolItem = await findItemByKey(guildId, toolKeyUsed); if (!toolItem) throw new Error('Herramienta no encontrada'); - const { entry } = await getInventoryEntry(userId, guildId, toolKey); + const { entry } = await getInventoryEntry(userId, guildId, toolKeyUsed); if (!entry || (entry.quantity ?? 0) <= 0) throw new Error('No tienes la herramienta'); const props = parseItemProps(toolItem.props); const tool = props.tool; if (toolReq.toolType && tool?.type !== toolReq.toolType) throw new Error('Tipo de herramienta incorrecto'); if (toolReq.minTier != null && (tool?.tier ?? 0) < toolReq.minTier) throw new Error('Tier de herramienta insuficiente'); - if (toolReq.allowedKeys && !toolReq.allowedKeys.includes(toolKey)) throw new Error('Esta herramienta no es válida para esta área'); + if (toolReq.allowedKeys && !toolReq.allowedKeys.includes(toolKeyUsed)) throw new Error('Esta herramienta no es válida para esta área'); - return { toolKeyUsed: toolKey }; + return { toolKeyUsed }; } async function applyRewards(userId: string, guildId: string, rewards?: RewardsTable): Promise { @@ -206,3 +229,18 @@ export async function runMinigame(userId: string, guildId: string, areaKey: stri return { success: true, rewards: delivered, mobs: mobsSpawned, tool: toolInfo }; } + +// Convenience wrappers with auto-level (from PlayerProgress) and auto-tool selection inside validateRequirements +export async function runMining(userId: string, guildId: string, level?: number, toolKey?: string) { + const area = await prisma.gameArea.findFirst({ where: { key: 'mine.cavern', OR: [{ guildId }, { guildId: null }] }, orderBy: [{ guildId: 'desc' }] }); + if (!area) throw new Error('Área de mina no configurada'); + const lvl = level ?? (await prisma.playerProgress.findUnique({ where: { userId_guildId_areaId: { userId, guildId, areaId: area.id } } }))?.highestLevel ?? 1; + return runMinigame(userId, guildId, 'mine.cavern', Math.max(1, lvl), { toolKey }); +} + +export async function runFishing(userId: string, guildId: string, level?: number, toolKey?: string) { + const area = await prisma.gameArea.findFirst({ where: { key: 'lagoon.shore', OR: [{ guildId }, { guildId: null }] }, orderBy: [{ guildId: 'desc' }] }); + if (!area) throw new Error('Área de laguna no configurada'); + const lvl = level ?? (await prisma.playerProgress.findUnique({ where: { userId_guildId_areaId: { userId, guildId, areaId: area.id } } }))?.highestLevel ?? 1; + return runMinigame(userId, guildId, 'lagoon.shore', Math.max(1, lvl), { toolKey }); +} diff --git a/src/game/mutations/service.ts b/src/game/mutations/service.ts new file mode 100644 index 0000000..1ea9dfc --- /dev/null +++ b/src/game/mutations/service.ts @@ -0,0 +1,32 @@ +import { prisma } from '../../core/database/prisma'; +import type { ItemProps } from '../economy/types'; +import { findItemByKey, getInventoryEntry } from '../economy/service'; + +function parseItemProps(json: unknown): ItemProps { + if (!json || typeof json !== 'object') return {}; + return json as ItemProps; +} + +export async function findMutationByKey(guildId: string, key: string) { + return prisma.itemMutation.findFirst({ + where: { key, OR: [{ guildId }, { guildId: null }] }, + 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'); + + 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'); + + const mutation = await findMutationByKey(guildId, mutationKey); + if (!mutation) throw new Error('Mutación no encontrada'); + + await prisma.inventoryItemMutation.create({ data: { inventoryId: entry.id, mutationId: mutation.id } }); + return { ok: true } as const; +} + diff --git a/src/game/smelting/service.ts b/src/game/smelting/service.ts index caac063..ee5e08a 100644 --- a/src/game/smelting/service.ts +++ b/src/game/smelting/service.ts @@ -13,8 +13,8 @@ export async function createSmeltJob(userId: string, guildId: string, inputs: Sm const outItem = await findItemByKey(guildId, outputItemKey); if (!outItem) throw new Error('Output item no encontrado'); + let newJobId: string | null = null; // Validar y descontar inputs - // Nota: para simplificar, chequeo y descuento debería ser transaccional; usamos una transacción await prisma.$transaction(async (tx) => { // Chequeo for (const i of inputs) { @@ -30,7 +30,7 @@ export async function createSmeltJob(userId: string, guildId: string, inputs: Sm await tx.inventoryEntry.update({ where: { userId_guildId_itemId: { userId, guildId, itemId: it.id } }, data: { quantity: { decrement: i.qty } } }); } // Crear job - await tx.smeltJob.create({ + const created = await tx.smeltJob.create({ data: { userId, guildId, @@ -40,10 +40,12 @@ export async function createSmeltJob(userId: string, guildId: string, inputs: Sm readyAt, status: 'pending', }, + select: { id: true }, }); + newJobId = created.id; }); - return { readyAt } as const; + return { readyAt, jobId: newJobId! } as const; } export async function claimSmeltJob(userId: string, guildId: string, jobId: string) { @@ -52,13 +54,10 @@ export async function claimSmeltJob(userId: string, guildId: string, jobId: stri if (job.status !== 'pending' && job.status !== 'ready') throw new Error('Estado inválido'); if (job.readyAt > new Date()) throw new Error('Aún no está listo'); - // Otorgar outputs y marcar claimed await prisma.$transaction(async (tx) => { await tx.smeltJob.update({ where: { id: job.id }, data: { status: 'claimed' } }); const outItem = await tx.economyItem.findUnique({ where: { id: job.outputItemId } }); if (outItem) { - // usamos servicio economy por fuera de la transacción (para evitar nested client); hacemos simple aquí - // añadir con tx: replicamos addItem const inv = await tx.inventoryEntry.findUnique({ where: { userId_guildId_itemId: { userId, guildId, itemId: outItem.id } } }); if (inv) { await tx.inventoryEntry.update({ where: { userId_guildId_itemId: { userId, guildId, itemId: outItem.id } }, data: { quantity: { increment: job.outputQty } } }); @@ -71,3 +70,12 @@ export async function claimSmeltJob(userId: string, guildId: string, jobId: stri return { ok: true } as const; } +export async function claimNextReadyJob(userId: string, guildId: string) { + const job = await prisma.smeltJob.findFirst({ + where: { userId, guildId, status: { in: ['pending', 'ready'] }, readyAt: { lte: new Date() } }, + orderBy: { readyAt: 'asc' }, + }); + if (!job) throw new Error('No hay jobs listos'); + await claimSmeltJob(userId, guildId, job.id); + return { ok: true, jobId: job.id } as const; +}