502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
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 {
|
|
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) {
|
|
// 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: {},
|
|
create: { userId, guildId, coins: 25 },
|
|
});
|
|
}
|
|
|
|
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))
|
|
);
|
|
// Inicializar durabilidad si corresponde
|
|
const props = parseItemProps(item.props);
|
|
const breakable = props.breakable;
|
|
const maxDurability =
|
|
breakable?.enabled !== false ? breakable?.maxDurability : undefined;
|
|
for (let i = 0; i < canAdd; i++) {
|
|
if (maxDurability && maxDurability > 0) {
|
|
state.instances.push({ durability: maxDurability });
|
|
} else {
|
|
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 mode = chest.randomMode || "all";
|
|
const result: OpenChestResult = {
|
|
coinsDelta: 0,
|
|
itemsToAdd: [],
|
|
rolesToGrant: [],
|
|
consumed: false,
|
|
};
|
|
|
|
function pickOneWeighted<T extends { probability?: number }>(
|
|
arr: T[]
|
|
): T | null {
|
|
const prepared = arr.map((a) => ({
|
|
...a,
|
|
_w: a.probability != null ? Math.max(0, a.probability) : 1,
|
|
}));
|
|
const total = prepared.reduce((s, a) => s + a._w, 0);
|
|
if (total <= 0) return null;
|
|
let r = Math.random() * total;
|
|
for (const a of prepared) {
|
|
r -= a._w;
|
|
if (r <= 0) return a;
|
|
}
|
|
return prepared[prepared.length - 1] ?? null;
|
|
}
|
|
|
|
if (mode === "single") {
|
|
const one = pickOneWeighted(rewards);
|
|
if (one) {
|
|
if (one.type === "coins") result.coinsDelta += Math.max(0, one.amount);
|
|
else if (one.type === "item")
|
|
result.itemsToAdd.push({
|
|
itemKey: one.itemKey,
|
|
itemId: one.itemId,
|
|
qty: one.qty,
|
|
});
|
|
else if (one.type === "role") result.rolesToGrant.push(one.roleId);
|
|
}
|
|
} else {
|
|
// 'all' y 'roll-each': procesar cada reward con probabilidad (default 100%)
|
|
for (const r of rewards) {
|
|
const p = r.probability != null ? Math.max(0, r.probability) : 1; // p en [0,1] recomendado; si usan valores >1 se interpretan como peso
|
|
// Si p > 1 asumimos error o peso -> para modo 'all' lo tratamos como 1 (100%)
|
|
const chance = p > 1 ? 1 : p; // normalizado
|
|
if (Math.random() <= chance) {
|
|
if (r.type === "coins") result.coinsDelta += Math.max(0, r.amount);
|
|
else if (r.type === "item")
|
|
result.itemsToAdd.push({
|
|
itemKey: r.itemKey,
|
|
itemId: r.itemId,
|
|
qty: r.qty,
|
|
});
|
|
else if (r.type === "role") result.rolesToGrant.push(r.roleId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Roles fijos adicionales en chest.roles
|
|
if (Array.isArray(chest.roles) && chest.roles.length) {
|
|
for (const roleId of chest.roles) {
|
|
if (typeof roleId === "string" && roleId.length > 0)
|
|
result.rolesToGrant.push(roleId);
|
|
}
|
|
}
|
|
|
|
if (result.coinsDelta) await adjustCoins(userId, guildId, result.coinsDelta);
|
|
for (const it of result.itemsToAdd) {
|
|
if (it.itemKey) await addItemByKey(userId, guildId, it.itemKey, it.qty);
|
|
else if (it.itemId) {
|
|
const item = await prisma.economyItem.findUnique({
|
|
where: { id: it.itemId },
|
|
});
|
|
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;
|
|
}
|