diff --git a/src/game/economy/service.ts b/src/game/economy/service.ts new file mode 100644 index 0000000..b19c043 --- /dev/null +++ b/src/game/economy/service.ts @@ -0,0 +1,326 @@ +import { prisma } from '../../core/database/prisma'; +import type { ItemProps, InventoryState, Price, OpenChestResult } from './types'; +import type { Prisma } from '@prisma/client'; + +// Utilidades de tiempo +function now(): Date { + return new Date(); +} + +function isWithin(date: Date, from?: Date | null, to?: Date | null): boolean { + if (from && date < from) return false; + if (to && date > to) return false; + return true; +} + +// Resuelve un EconomyItem por key con alcance de guild o global +export async function findItemByKey(guildId: string, key: string) { + // buscamos ítem por guildId específico primero, si no, por global (guildId null) + const item = await prisma.economyItem.findFirst({ + where: { + key, + OR: [{ guildId }, { guildId: null }], + }, + orderBy: [ + // preferir coincidencia del servidor + { guildId: 'desc' }, + ], + }); + return item; +} + +export async function getOrCreateWallet(userId: string, guildId: string) { + return prisma.economyWallet.upsert({ + where: { userId_guildId: { userId, guildId } }, + update: {}, + create: { userId, guildId }, + }); +} + +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({ + where: { userId_guildId: { userId, guildId } }, + data: { coins: next }, + }); +} + +export type EnsureInventoryOptions = { createIfMissing?: boolean }; + +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 } }); +} + +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); + return { item, entry } as const; +} + +function parseItemProps(json: unknown): ItemProps { + if (!json || typeof json !== 'object') return {}; + return json as ItemProps; +} + +function parseState(json: unknown): InventoryState { + if (!json || typeof json !== 'object') return {}; + return json as InventoryState; +} + +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'); + } +} + +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 to = props.availableTo ? new Date(props.availableTo) : item.availableTo; + if (!isWithin(now(), from ?? null, to ?? null)) { + 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) { + if (qty <= 0) return { added: 0 } as const; + 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'); + checkAvailableWindow(item); + + const max = item.maxPerInventory ?? Number.MAX_SAFE_INTEGER; + if (item.stackable) { + const currentQty = entry.quantity ?? 0; + const added = Math.max(0, Math.min(qty, Math.max(0, max - currentQty))); + if (added === 0) return { added: 0 } as const; + const updated = await prisma.inventoryEntry.update({ + where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, + data: { quantity: { increment: added } }, + }); + return { added, entry: updated } as const; + } else { + // 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))); + 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 }, + }); + return { added: canAdd, entry: updated } as const; + } +} + +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; + + if (item.stackable) { + const consumed = Math.min(qty, entry.quantity); + const updated = await prisma.inventoryEntry.update({ + where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, + data: { quantity: { decrement: consumed } }, + }); + return { consumed, entry: updated } as const; + } else { + const state = parseState(entry.state); + const instances = state.instances ?? []; + const consumed = Math.min(qty, instances.length); + if (consumed === 0) return { consumed: 0 } as const; + instances.splice(0, consumed); + 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 }, + }); + return { consumed, entry: updated } as const; + } +} + +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'); + checkUsableWindow(item); + + const props = parseItemProps(item.props); + const chest = props.chest ?? {}; + 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 }; + + 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); + } + + 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 } }); + if (item) await addItemByKey(userId, guildId, item.key, it.qty); + } + } + + if (chest.consumeOnOpen) { + const c = await consumeItemByKey(userId, guildId, itemKey, 1); + result.consumed = c.consumed > 0; + } + + return result; +} + +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'); + + // 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 have = inv?.quantity ?? 0; + if (have < ing.quantity) shortages.push(ing.itemId); + } + if (shortages.length) throw new Error('Ingredientes insuficientes'); + + // Consumir ingredientes + for (const ing of recipe.ingredients) { + await prisma.inventoryEntry.update({ + where: { userId_guildId_itemId: { userId, guildId, itemId: ing.itemId } }, + data: { quantity: { decrement: ing.quantity } }, + }); + } + + // Agregar producto + 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'); + 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'); + const nowD = now(); + 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 + if (offer.perUserLimit != null) { + const count = await prisma.shopPurchase.aggregate({ + where: { offerId: offer.id, userId, guildId }, + _sum: { qty: true }, + }); + const already = count._sum.qty ?? 0; + 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'); + } + + // 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 } }); + } + // Cobro: items + if (price.items && price.items.length) { + for (const comp of price.items) { + const key = comp.itemKey; + const compQty = comp.qty * qty; + let itemId: string | null = null; + if (key) { + const it = await findItemByKey(guildId, key); + if (!it) throw new Error(`Item de precio no encontrado: ${key}`); + itemId = it.id; + } 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'); + } + // si todo está ok, descontar + for (const comp of price.items) { + const key = comp.itemKey; + const compQty = comp.qty * qty; + let itemId: string | null = null; + if (key) { + const it = await findItemByKey(guildId, key); + itemId = it?.id ?? null; + } else if (comp.itemId) { + itemId = comp.itemId; + } + if (!itemId) continue; + 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'); + await addItemByKey(userId, guildId, item.key, qty); + + // Registrar compra + 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 } }); + } + + return { ok: true, item, qty } as const; +} + +// --------------------------- +// Mutaciones +// --------------------------- +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'); + + // 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'); + + const mutation = await findMutationByKey(guildId, mutationKey); + if (!mutation) throw new Error('Mutación no encontrada'); + + // Registrar vínculo + await prisma.inventoryItemMutation.create({ data: { inventoryId: entry.id, mutationId: mutation.id } }); + return { ok: true } as const; +}