Refactor economy service and types for improved readability and functionality

- Reformatted code for consistent styling and indentation in service.ts
- Enhanced item reward structure in types.ts to include probability for chest rewards
- Added randomization modes for chest rewards: 'all', 'single', and 'roll-each'
- Updated functions in service.ts to handle new reward structures and improve error handling
- Ensured better organization and clarity in function parameters and return types
This commit is contained in:
2025-10-08 23:34:55 -05:00
parent 03f66f2f82
commit 44aad43df6
4 changed files with 1246 additions and 531 deletions

View File

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

View File

@@ -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