feat(economy): implement inventory management and economy features; add item handling, wallet management, and shop interactions

This commit is contained in:
2025-10-05 00:03:57 -05:00
parent 581b7b1bd2
commit a5d4e87444

326
src/game/economy/service.ts Normal file
View File

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