2025-10-08 23:18:34 -05:00
|
|
|
import { prisma } from "../../core/database/prisma";
|
|
|
|
|
import {
|
|
|
|
|
addItemByKey,
|
|
|
|
|
adjustCoins,
|
|
|
|
|
findItemByKey,
|
|
|
|
|
getInventoryEntry,
|
|
|
|
|
} from "../economy/service";
|
2025-10-09 00:44:30 -05:00
|
|
|
import {
|
|
|
|
|
getEffectiveStats,
|
|
|
|
|
adjustHP,
|
|
|
|
|
ensurePlayerState,
|
|
|
|
|
} from "../combat/equipmentService"; // 🟩 local authoritative
|
|
|
|
|
import { logToolBreak } from "../lib/toolBreakLog";
|
|
|
|
|
import { updateStats } from "../stats/service"; // 🟩 local authoritative
|
2025-10-08 23:18:34 -05:00
|
|
|
import type { ItemProps, InventoryState } from "../economy/types";
|
|
|
|
|
import type {
|
|
|
|
|
LevelRequirements,
|
|
|
|
|
RunMinigameOptions,
|
|
|
|
|
RunResult,
|
|
|
|
|
RewardsTable,
|
|
|
|
|
MobsTable,
|
2025-10-09 00:12:56 -05:00
|
|
|
CombatSummary,
|
2025-10-08 23:18:34 -05:00
|
|
|
} from "./types";
|
|
|
|
|
import type { Prisma } from "@prisma/client";
|
2025-10-05 00:51:16 -05:00
|
|
|
|
2025-10-05 02:08:59 -05:00
|
|
|
// Auto-select best tool from inventory by type and constraints
|
2025-10-08 23:18:34 -05:00
|
|
|
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 },
|
|
|
|
|
});
|
2025-10-05 02:08:59 -05:00
|
|
|
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;
|
2025-10-08 23:18:34 -05:00
|
|
|
if (
|
|
|
|
|
opts?.allowedKeys &&
|
|
|
|
|
opts.allowedKeys.length &&
|
|
|
|
|
!opts.allowedKeys.includes(e.item.key)
|
|
|
|
|
)
|
|
|
|
|
continue;
|
2025-10-05 02:08:59 -05:00
|
|
|
if (!best || tier > best.tier) best = { key: e.item.key, tier };
|
|
|
|
|
}
|
|
|
|
|
return best?.key ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-05 00:51:16 -05:00
|
|
|
function parseJSON<T>(v: unknown): T | null {
|
2025-10-08 23:18:34 -05:00
|
|
|
if (!v || (typeof v !== "object" && typeof v !== "string")) return null;
|
2025-10-05 00:51:16 -05:00
|
|
|
return v as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickWeighted<T extends { weight: number }>(arr: T[]): T | null {
|
|
|
|
|
const total = arr.reduce((s, a) => s + Math.max(0, a.weight || 0), 0);
|
|
|
|
|
if (total <= 0) return null;
|
|
|
|
|
const r = Math.random() * total;
|
|
|
|
|
let acc = 0;
|
|
|
|
|
for (const a of arr) {
|
|
|
|
|
acc += Math.max(0, a.weight || 0);
|
|
|
|
|
if (r <= acc) return a;
|
|
|
|
|
}
|
|
|
|
|
return arr[arr.length - 1] ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 23:18:34 -05:00
|
|
|
async function ensureAreaAndLevel(
|
|
|
|
|
guildId: string,
|
|
|
|
|
areaKey: string,
|
|
|
|
|
level: number
|
|
|
|
|
) {
|
|
|
|
|
const area = await prisma.gameArea.findFirst({
|
|
|
|
|
where: { key: areaKey, OR: [{ guildId }, { guildId: null }] },
|
|
|
|
|
orderBy: [{ guildId: "desc" }],
|
|
|
|
|
});
|
|
|
|
|
if (!area) throw new Error("Área no encontrada");
|
|
|
|
|
const lvl = await prisma.gameAreaLevel.findFirst({
|
|
|
|
|
where: { areaId: area.id, level },
|
|
|
|
|
});
|
|
|
|
|
if (!lvl) throw new Error("Nivel no encontrado");
|
2025-10-05 00:51:16 -05:00
|
|
|
return { area, lvl } as const;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseItemProps(json: unknown): ItemProps {
|
2025-10-08 23:18:34 -05:00
|
|
|
if (!json || typeof json !== "object") return {};
|
2025-10-05 00:51:16 -05:00
|
|
|
return json as ItemProps;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseInvState(json: unknown): InventoryState {
|
2025-10-08 23:18:34 -05:00
|
|
|
if (!json || typeof json !== "object") return {};
|
2025-10-05 00:51:16 -05:00
|
|
|
return json as InventoryState;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 23:18:34 -05:00
|
|
|
async function validateRequirements(
|
|
|
|
|
userId: string,
|
|
|
|
|
guildId: string,
|
|
|
|
|
req?: LevelRequirements,
|
|
|
|
|
toolKey?: string
|
|
|
|
|
) {
|
2025-10-09 00:44:30 -05:00
|
|
|
if (!req)
|
|
|
|
|
return {
|
|
|
|
|
toolKeyUsed: undefined as string | undefined,
|
|
|
|
|
toolSource: undefined as "provided" | "equipped" | "auto" | undefined,
|
|
|
|
|
};
|
2025-10-05 00:51:16 -05:00
|
|
|
const toolReq = req.tool;
|
2025-10-09 00:44:30 -05:00
|
|
|
if (!toolReq)
|
|
|
|
|
return {
|
|
|
|
|
toolKeyUsed: undefined as string | undefined,
|
|
|
|
|
toolSource: undefined,
|
|
|
|
|
};
|
2025-10-05 00:51:16 -05:00
|
|
|
|
2025-10-05 02:08:59 -05:00
|
|
|
let toolKeyUsed = toolKey;
|
2025-10-09 00:44:30 -05:00
|
|
|
let toolSource: "provided" | "equipped" | "auto" | undefined = undefined;
|
|
|
|
|
if (toolKeyUsed) toolSource = "provided";
|
2025-10-05 02:08:59 -05:00
|
|
|
|
|
|
|
|
// Auto-select tool when required and not provided
|
|
|
|
|
if (!toolKeyUsed && toolReq.required && toolReq.toolType) {
|
2025-10-09 00:44:30 -05:00
|
|
|
// 1. Intentar herramienta equipada en slot weapon si coincide el tipo
|
|
|
|
|
const equip = await prisma.playerEquipment.findUnique({
|
|
|
|
|
where: { userId_guildId: { userId, guildId } },
|
|
|
|
|
include: { weaponItem: true } as any,
|
|
|
|
|
});
|
|
|
|
|
if (equip?.weaponItemId && equip?.weaponItem) {
|
|
|
|
|
const wProps = parseItemProps((equip as any).weaponItem.props);
|
|
|
|
|
if (wProps.tool?.type === toolReq.toolType) {
|
|
|
|
|
const tier = Math.max(0, wProps.tool?.tier ?? 0);
|
|
|
|
|
if (
|
|
|
|
|
(toolReq.minTier == null || tier >= toolReq.minTier) &&
|
|
|
|
|
(!toolReq.allowedKeys ||
|
|
|
|
|
toolReq.allowedKeys.includes((equip as any).weaponItem.key))
|
|
|
|
|
) {
|
|
|
|
|
toolKeyUsed = (equip as any).weaponItem.key;
|
|
|
|
|
toolSource = "equipped";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 2. Best inventory si no se obtuvo del equipo
|
|
|
|
|
if (!toolKeyUsed) {
|
|
|
|
|
const best = await findBestToolKey(userId, guildId, toolReq.toolType, {
|
2025-10-08 23:18:34 -05:00
|
|
|
minTier: toolReq.minTier,
|
|
|
|
|
allowedKeys: toolReq.allowedKeys,
|
2025-10-09 00:44:30 -05:00
|
|
|
});
|
|
|
|
|
if (best) {
|
|
|
|
|
toolKeyUsed = best;
|
|
|
|
|
toolSource = "auto";
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-05 02:08:59 -05:00
|
|
|
}
|
|
|
|
|
|
2025-10-05 00:51:16 -05:00
|
|
|
// herramienta requerida
|
2025-10-08 23:18:34 -05:00
|
|
|
if (toolReq.required && !toolKeyUsed)
|
|
|
|
|
throw new Error("Se requiere una herramienta adecuada");
|
2025-10-09 00:44:30 -05:00
|
|
|
if (!toolKeyUsed) return { toolKeyUsed: undefined, toolSource };
|
2025-10-05 00:51:16 -05:00
|
|
|
|
|
|
|
|
// verificar herramienta
|
2025-10-05 02:08:59 -05:00
|
|
|
const toolItem = await findItemByKey(guildId, toolKeyUsed);
|
2025-10-08 23:18:34 -05:00
|
|
|
if (!toolItem) throw new Error("Herramienta no encontrada");
|
2025-10-05 02:08:59 -05:00
|
|
|
const { entry } = await getInventoryEntry(userId, guildId, toolKeyUsed);
|
2025-10-08 23:18:34 -05:00
|
|
|
if (!entry || (entry.quantity ?? 0) <= 0)
|
|
|
|
|
throw new Error("No tienes la herramienta");
|
2025-10-05 00:51:16 -05:00
|
|
|
|
|
|
|
|
const props = parseItemProps(toolItem.props);
|
|
|
|
|
const tool = props.tool;
|
2025-10-08 23:18:34 -05:00
|
|
|
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(toolKeyUsed))
|
|
|
|
|
throw new Error("Esta herramienta no es válida para esta área");
|
2025-10-05 00:51:16 -05:00
|
|
|
|
2025-10-09 00:44:30 -05:00
|
|
|
return { toolKeyUsed, toolSource };
|
2025-10-05 00:51:16 -05:00
|
|
|
}
|
|
|
|
|
|
2025-10-08 23:18:34 -05:00
|
|
|
async function applyRewards(
|
|
|
|
|
userId: string,
|
|
|
|
|
guildId: string,
|
|
|
|
|
rewards?: RewardsTable
|
|
|
|
|
): Promise<RunResult["rewards"]> {
|
|
|
|
|
const results: RunResult["rewards"] = [];
|
|
|
|
|
if (!rewards || !Array.isArray(rewards.table) || rewards.table.length === 0)
|
|
|
|
|
return results;
|
2025-10-05 00:51:16 -05:00
|
|
|
const draws = Math.max(1, rewards.draws ?? 1);
|
|
|
|
|
for (let i = 0; i < draws; i++) {
|
|
|
|
|
const pick = pickWeighted(rewards.table);
|
|
|
|
|
if (!pick) continue;
|
2025-10-08 23:18:34 -05:00
|
|
|
if (pick.type === "coins") {
|
2025-10-05 00:51:16 -05:00
|
|
|
const amt = Math.max(0, pick.amount);
|
|
|
|
|
if (amt > 0) {
|
|
|
|
|
await adjustCoins(userId, guildId, amt);
|
2025-10-08 23:18:34 -05:00
|
|
|
results.push({ type: "coins", amount: amt });
|
2025-10-05 00:51:16 -05:00
|
|
|
}
|
2025-10-08 23:18:34 -05:00
|
|
|
} else if (pick.type === "item") {
|
2025-10-05 00:51:16 -05:00
|
|
|
const qty = Math.max(1, pick.qty);
|
|
|
|
|
await addItemByKey(userId, guildId, pick.itemKey, qty);
|
2025-10-08 23:18:34 -05:00
|
|
|
results.push({ type: "item", itemKey: pick.itemKey, qty });
|
2025-10-05 00:51:16 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function sampleMobs(mobs?: MobsTable): Promise<string[]> {
|
|
|
|
|
const out: string[] = [];
|
2025-10-08 23:18:34 -05:00
|
|
|
if (!mobs || !Array.isArray(mobs.table) || mobs.table.length === 0)
|
|
|
|
|
return out;
|
2025-10-05 00:51:16 -05:00
|
|
|
const draws = Math.max(0, mobs.draws ?? 0);
|
|
|
|
|
for (let i = 0; i < draws; i++) {
|
|
|
|
|
const pick = pickWeighted(mobs.table);
|
|
|
|
|
if (pick) out.push(pick.mobKey);
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 23:18:34 -05:00
|
|
|
async function reduceToolDurability(
|
|
|
|
|
userId: string,
|
|
|
|
|
guildId: string,
|
|
|
|
|
toolKey: string
|
|
|
|
|
) {
|
2025-10-05 00:51:16 -05:00
|
|
|
const { item, entry } = await getInventoryEntry(userId, guildId, toolKey);
|
2025-10-09 00:12:56 -05:00
|
|
|
if (!entry)
|
|
|
|
|
return {
|
|
|
|
|
broken: false,
|
|
|
|
|
brokenInstance: false,
|
|
|
|
|
delta: 0,
|
|
|
|
|
remaining: undefined,
|
|
|
|
|
max: undefined,
|
|
|
|
|
instancesRemaining: 0,
|
|
|
|
|
} as const;
|
2025-10-05 00:51:16 -05:00
|
|
|
const props = parseItemProps(item.props);
|
|
|
|
|
const breakable = props.breakable;
|
2025-10-08 23:18:34 -05:00
|
|
|
// Si el item no es breakable o la durabilidad está deshabilitada, no hacemos nada
|
|
|
|
|
if (!breakable || breakable.enabled === false) {
|
2025-10-09 00:12:56 -05:00
|
|
|
return {
|
|
|
|
|
broken: false,
|
|
|
|
|
brokenInstance: false,
|
|
|
|
|
delta: 0,
|
|
|
|
|
remaining: undefined,
|
|
|
|
|
max: undefined,
|
|
|
|
|
instancesRemaining: entry.quantity ?? 0,
|
|
|
|
|
} as const;
|
2025-10-08 23:18:34 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Valores base
|
|
|
|
|
const maxConfigured = Math.max(1, breakable.maxDurability ?? 1);
|
|
|
|
|
let perUse = Math.max(1, breakable.durabilityPerUse ?? 1);
|
|
|
|
|
|
|
|
|
|
// Protección: si perUse > maxDurability asumimos configuración errónea y lo reducimos a 1
|
|
|
|
|
// (en lugar de romper inmediatamente el ítem). Si quieres que se rompa de un uso, define maxDurability igual a 1.
|
|
|
|
|
if (perUse > maxConfigured) perUse = 1;
|
|
|
|
|
const delta = perUse;
|
2025-10-05 00:51:16 -05:00
|
|
|
if (item.stackable) {
|
|
|
|
|
// Herramientas deberían ser no apilables; si lo son, solo decrementamos cantidad como fallback
|
|
|
|
|
const consumed = Math.min(1, entry.quantity);
|
2025-10-09 00:12:56 -05:00
|
|
|
let broken = false;
|
2025-10-05 00:51:16 -05:00
|
|
|
if (consumed > 0) {
|
2025-10-09 00:12:56 -05:00
|
|
|
const updated = await prisma.inventoryEntry.update({
|
2025-10-08 23:18:34 -05:00
|
|
|
where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } },
|
|
|
|
|
data: { quantity: { decrement: consumed } },
|
|
|
|
|
});
|
2025-10-09 00:12:56 -05:00
|
|
|
// Consideramos "rota" sólo si después de consumir ya no queda ninguna unidad
|
|
|
|
|
broken = (updated.quantity ?? 0) <= 0;
|
2025-10-05 00:51:16 -05:00
|
|
|
}
|
2025-10-09 00:14:07 -05:00
|
|
|
return {
|
|
|
|
|
broken,
|
|
|
|
|
brokenInstance: broken,
|
|
|
|
|
delta,
|
|
|
|
|
remaining: undefined,
|
|
|
|
|
max: maxConfigured,
|
|
|
|
|
instancesRemaining: broken ? 0 : (entry.quantity ?? 1) - 1,
|
|
|
|
|
} as const;
|
2025-10-05 00:51:16 -05:00
|
|
|
}
|
|
|
|
|
const state = parseInvState(entry.state);
|
|
|
|
|
state.instances ??= [{}];
|
|
|
|
|
if (state.instances.length === 0) state.instances.push({});
|
2025-10-09 00:12:56 -05:00
|
|
|
// Seleccionar instancia: ahora usamos la primera, en futuro se puede elegir la de mayor durabilidad restante
|
2025-10-05 00:51:16 -05:00
|
|
|
const inst = state.instances[0];
|
2025-10-08 23:18:34 -05:00
|
|
|
const max = maxConfigured; // ya calculado arriba
|
2025-10-09 00:12:56 -05:00
|
|
|
// Si la instancia no tiene durabilidad inicial, la inicializamos
|
|
|
|
|
if (inst.durability == null) (inst as any).durability = max;
|
2025-10-05 00:51:16 -05:00
|
|
|
const current = Math.min(Math.max(0, inst.durability ?? max), max);
|
|
|
|
|
const next = current - delta;
|
2025-10-09 00:12:56 -05:00
|
|
|
let brokenInstance = false;
|
2025-10-05 00:51:16 -05:00
|
|
|
if (next <= 0) {
|
2025-10-09 00:12:56 -05:00
|
|
|
// romper sólo esta instancia
|
2025-10-05 00:51:16 -05:00
|
|
|
state.instances.shift();
|
2025-10-09 00:12:56 -05:00
|
|
|
brokenInstance = true;
|
2025-10-05 00:51:16 -05:00
|
|
|
} else {
|
|
|
|
|
(inst as any).durability = next;
|
|
|
|
|
state.instances[0] = inst;
|
|
|
|
|
}
|
2025-10-09 00:12:56 -05:00
|
|
|
const instancesRemaining = state.instances.length;
|
|
|
|
|
const broken = instancesRemaining === 0; // Ítem totalmente agotado
|
2025-10-05 00:51:16 -05:00
|
|
|
await prisma.inventoryEntry.update({
|
|
|
|
|
where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } },
|
2025-10-08 23:18:34 -05:00
|
|
|
data: {
|
|
|
|
|
state: state as unknown as Prisma.InputJsonValue,
|
|
|
|
|
quantity: state.instances.length,
|
|
|
|
|
},
|
2025-10-05 00:51:16 -05:00
|
|
|
});
|
2025-10-09 00:12:56 -05:00
|
|
|
// Placeholder: logging de ruptura (migrar a ToolBreakLog futuro)
|
|
|
|
|
if (brokenInstance) {
|
2025-10-09 00:44:30 -05:00
|
|
|
logToolBreak({
|
|
|
|
|
ts: Date.now(),
|
|
|
|
|
userId,
|
|
|
|
|
guildId,
|
|
|
|
|
toolKey,
|
|
|
|
|
brokenInstance: !broken, // true = solo una instancia
|
|
|
|
|
instancesRemaining,
|
|
|
|
|
});
|
2025-10-09 00:12:56 -05:00
|
|
|
}
|
2025-10-09 00:14:07 -05:00
|
|
|
return {
|
|
|
|
|
broken,
|
|
|
|
|
brokenInstance,
|
|
|
|
|
delta,
|
|
|
|
|
remaining: broken ? 0 : next,
|
|
|
|
|
max,
|
|
|
|
|
instancesRemaining,
|
|
|
|
|
} as const;
|
2025-10-05 00:51:16 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { reduceToolDurability };
|
|
|
|
|
|
2025-10-08 23:18:34 -05:00
|
|
|
export async function runMinigame(
|
|
|
|
|
userId: string,
|
|
|
|
|
guildId: string,
|
|
|
|
|
areaKey: string,
|
|
|
|
|
level: number,
|
|
|
|
|
opts?: RunMinigameOptions
|
|
|
|
|
): Promise<RunResult> {
|
2025-10-05 00:51:16 -05:00
|
|
|
const { area, lvl } = await ensureAreaAndLevel(guildId, areaKey, level);
|
|
|
|
|
|
|
|
|
|
// Cooldown por área
|
|
|
|
|
const areaConf = (area.config as any) ?? {};
|
|
|
|
|
const cdSeconds = Math.max(0, Number(areaConf.cooldownSeconds ?? 0));
|
|
|
|
|
const cdKey = `minigame:${area.key}`;
|
|
|
|
|
if (cdSeconds > 0) {
|
2025-10-08 23:18:34 -05:00
|
|
|
const existing = await prisma.actionCooldown.findUnique({
|
|
|
|
|
where: { userId_guildId_key: { userId, guildId, key: cdKey } },
|
|
|
|
|
});
|
2025-10-05 00:51:16 -05:00
|
|
|
if (existing && existing.until > new Date()) {
|
2025-10-08 23:18:34 -05:00
|
|
|
throw new Error("Cooldown activo para esta actividad");
|
2025-10-05 00:51:16 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Leer configuración de nivel (requirements, rewards, mobs)
|
|
|
|
|
const requirements = parseJSON<LevelRequirements>(lvl.requirements) ?? {};
|
|
|
|
|
const rewards = parseJSON<RewardsTable>(lvl.rewards) ?? { table: [] };
|
|
|
|
|
const mobs = parseJSON<MobsTable>(lvl.mobs) ?? { table: [] };
|
|
|
|
|
|
|
|
|
|
// Validar herramienta si aplica
|
2025-10-08 23:18:34 -05:00
|
|
|
const reqRes = await validateRequirements(
|
|
|
|
|
userId,
|
|
|
|
|
guildId,
|
|
|
|
|
requirements,
|
|
|
|
|
opts?.toolKey
|
|
|
|
|
);
|
2025-10-05 00:51:16 -05:00
|
|
|
|
|
|
|
|
// Aplicar recompensas y samplear mobs
|
|
|
|
|
const delivered = await applyRewards(userId, guildId, rewards);
|
|
|
|
|
const mobsSpawned = await sampleMobs(mobs);
|
|
|
|
|
|
|
|
|
|
// Reducir durabilidad de herramienta si se usó
|
2025-10-08 23:18:34 -05:00
|
|
|
let toolInfo: RunResult["tool"] | undefined;
|
2025-10-05 00:51:16 -05:00
|
|
|
if (reqRes.toolKeyUsed) {
|
|
|
|
|
const t = await reduceToolDurability(userId, guildId, reqRes.toolKeyUsed);
|
2025-10-08 23:18:34 -05:00
|
|
|
toolInfo = {
|
|
|
|
|
key: reqRes.toolKeyUsed,
|
|
|
|
|
durabilityDelta: t.delta,
|
|
|
|
|
broken: t.broken,
|
2025-10-09 00:12:56 -05:00
|
|
|
remaining: t.remaining,
|
|
|
|
|
max: t.max,
|
|
|
|
|
brokenInstance: t.brokenInstance,
|
|
|
|
|
instancesRemaining: t.instancesRemaining,
|
2025-10-09 00:44:30 -05:00
|
|
|
toolSource: reqRes.toolSource ?? (opts?.toolKey ? "provided" : "auto"),
|
2025-10-09 00:12:56 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-09 00:44:30 -05:00
|
|
|
// (Eliminado combate placeholder; sustituido por sistema integrado más abajo)
|
|
|
|
|
// --- Combate Integrado con Equipo y HP Persistente ---
|
2025-10-09 00:12:56 -05:00
|
|
|
let combatSummary: CombatSummary | undefined;
|
|
|
|
|
if (mobsSpawned.length > 0) {
|
2025-10-09 00:44:30 -05:00
|
|
|
// Obtener stats efectivos del jugador (arma = daño, armadura = defensa, capa = maxHp extra + mutaciones)
|
|
|
|
|
const eff = await getEffectiveStats(userId, guildId);
|
|
|
|
|
const playerState = await ensurePlayerState(userId, guildId);
|
|
|
|
|
const startHp = eff.hp; // HP actual persistente
|
|
|
|
|
let currentHp = startHp;
|
2025-10-09 00:12:56 -05:00
|
|
|
const mobLogs: CombatSummary["mobs"] = [];
|
|
|
|
|
let totalDealt = 0;
|
|
|
|
|
let totalTaken = 0;
|
2025-10-09 00:44:30 -05:00
|
|
|
let totalMobsDefeated = 0;
|
|
|
|
|
// Variación de ±20%
|
|
|
|
|
const variance = (base: number) => {
|
|
|
|
|
const factor = 0.8 + Math.random() * 0.4; // 0.8 - 1.2
|
|
|
|
|
return base * factor;
|
|
|
|
|
};
|
2025-10-09 00:12:56 -05:00
|
|
|
for (const mobKey of mobsSpawned) {
|
2025-10-09 00:44:30 -05:00
|
|
|
if (currentHp <= 0) break; // jugador derrotado antes de iniciar este mob
|
|
|
|
|
// Stats simples del mob (placeholder mejorable con tabla real)
|
|
|
|
|
const mobBaseHp = 10 + Math.floor(Math.random() * 6); // 10-15
|
|
|
|
|
let mobHp = mobBaseHp;
|
|
|
|
|
const rounds: any[] = [];
|
|
|
|
|
let round = 1;
|
|
|
|
|
let mobDamageDealt = 0; // daño que jugador hace a este mob
|
|
|
|
|
let mobDamageTakenFromMob = 0; // daño que jugador recibe de este mob
|
|
|
|
|
while (mobHp > 0 && currentHp > 0 && round <= 12) {
|
|
|
|
|
// Daño jugador -> mob
|
|
|
|
|
const playerRaw = variance(eff.damage || 1) + 1; // asegurar >=1
|
|
|
|
|
const playerDamage = Math.max(1, Math.round(playerRaw));
|
|
|
|
|
mobHp -= playerDamage;
|
|
|
|
|
mobDamageDealt += playerDamage;
|
2025-10-09 00:12:56 -05:00
|
|
|
totalDealt += playerDamage;
|
|
|
|
|
let playerTaken = 0;
|
2025-10-09 00:44:30 -05:00
|
|
|
if (mobHp > 0) {
|
|
|
|
|
const mobAtkBase = 3 + Math.random() * 4; // 3-7
|
|
|
|
|
const mobAtk = variance(mobAtkBase);
|
|
|
|
|
// Mitigación por defensa => defensa reduce linealmente hasta 60% cap
|
|
|
|
|
const mitigationRatio = Math.min(0.6, (eff.defense || 0) * 0.05); // 5% por punto defensa hasta 60%
|
|
|
|
|
const mitigated = mobAtk * (1 - mitigationRatio);
|
|
|
|
|
playerTaken = Math.max(0, Math.round(mitigated));
|
|
|
|
|
if (playerTaken > 0) {
|
|
|
|
|
currentHp = Math.max(0, currentHp - playerTaken);
|
|
|
|
|
mobDamageTakenFromMob += playerTaken;
|
|
|
|
|
totalTaken += playerTaken;
|
|
|
|
|
}
|
2025-10-09 00:12:56 -05:00
|
|
|
}
|
|
|
|
|
rounds.push({
|
|
|
|
|
mobKey,
|
2025-10-09 00:44:30 -05:00
|
|
|
round,
|
2025-10-09 00:12:56 -05:00
|
|
|
playerDamageDealt: playerDamage,
|
|
|
|
|
playerDamageTaken: playerTaken,
|
2025-10-09 00:44:30 -05:00
|
|
|
mobRemainingHp: Math.max(0, mobHp),
|
|
|
|
|
mobDefeated: mobHp <= 0,
|
2025-10-09 00:12:56 -05:00
|
|
|
});
|
2025-10-09 00:44:30 -05:00
|
|
|
if (mobHp <= 0) {
|
|
|
|
|
totalMobsDefeated++;
|
2025-10-09 00:12:56 -05:00
|
|
|
break;
|
|
|
|
|
}
|
2025-10-09 00:44:30 -05:00
|
|
|
if (currentHp <= 0) break;
|
|
|
|
|
round++;
|
2025-10-09 00:12:56 -05:00
|
|
|
}
|
|
|
|
|
mobLogs.push({
|
|
|
|
|
mobKey,
|
2025-10-09 00:44:30 -05:00
|
|
|
maxHp: mobBaseHp,
|
|
|
|
|
defeated: mobHp <= 0,
|
|
|
|
|
totalDamageDealt: mobDamageDealt,
|
|
|
|
|
totalDamageTakenFromMob: mobDamageTakenFromMob,
|
2025-10-09 00:12:56 -05:00
|
|
|
rounds,
|
|
|
|
|
});
|
2025-10-09 00:44:30 -05:00
|
|
|
if (currentHp <= 0) break; // fin combate global
|
|
|
|
|
}
|
|
|
|
|
const victory = currentHp > 0 && totalMobsDefeated === mobsSpawned.length;
|
|
|
|
|
// Persistir HP (si derrota -> regenerar al 50% del maxHp, regla confirmada por usuario)
|
|
|
|
|
let endHp = currentHp;
|
|
|
|
|
let defeatedNow = false;
|
|
|
|
|
if (currentHp <= 0) {
|
|
|
|
|
defeatedNow = true;
|
|
|
|
|
const regen = Math.max(1, Math.floor(eff.maxHp * 0.5));
|
|
|
|
|
endHp = regen;
|
|
|
|
|
await adjustHP(userId, guildId, regen - playerState.hp); // set a 50% (delta relativo)
|
|
|
|
|
} else {
|
|
|
|
|
// almacenar HP restante real
|
|
|
|
|
await adjustHP(userId, guildId, currentHp - playerState.hp);
|
|
|
|
|
}
|
|
|
|
|
// Actualizar estadísticas
|
|
|
|
|
const statUpdates: Record<string, number> = {};
|
|
|
|
|
if (area.key.startsWith("mine")) statUpdates.minesCompleted = 1;
|
|
|
|
|
if (area.key.startsWith("lagoon")) statUpdates.fishingCompleted = 1;
|
|
|
|
|
if (
|
|
|
|
|
area.key.startsWith("arena") ||
|
|
|
|
|
area.key.startsWith("battle") ||
|
|
|
|
|
area.key.includes("fight")
|
|
|
|
|
)
|
|
|
|
|
statUpdates.fightsCompleted = 1;
|
|
|
|
|
if (totalMobsDefeated > 0) statUpdates.mobsDefeated = totalMobsDefeated;
|
|
|
|
|
if (totalDealt > 0) statUpdates.damageDealt = totalDealt;
|
|
|
|
|
if (totalTaken > 0) statUpdates.damageTaken = totalTaken;
|
|
|
|
|
if (defeatedNow) statUpdates.timesDefeated = 1;
|
|
|
|
|
// Rachas de victoria
|
|
|
|
|
if (victory) {
|
|
|
|
|
statUpdates.currentWinStreak = 1; // increment
|
|
|
|
|
} else if (defeatedNow) {
|
|
|
|
|
// reset current streak
|
|
|
|
|
// No podemos hacer decrement directo, así que setearemos manual luego
|
|
|
|
|
}
|
|
|
|
|
await updateStats(userId, guildId, statUpdates as any);
|
|
|
|
|
if (defeatedNow) {
|
|
|
|
|
// reset de racha: update directo
|
|
|
|
|
await prisma.playerStats.update({
|
|
|
|
|
where: { userId_guildId: { userId, guildId } },
|
|
|
|
|
data: { currentWinStreak: 0 },
|
|
|
|
|
});
|
|
|
|
|
} else if (victory) {
|
|
|
|
|
// posible actualización de longestWinStreak si superada ya la maneja updateStats parcialmente; reforzar
|
|
|
|
|
await prisma.$executeRawUnsafe(
|
|
|
|
|
`UPDATE "PlayerStats" SET "longestWinStreak" = GREATEST("longestWinStreak", "currentWinStreak") WHERE "userId" = $1 AND "guildId" = $2`,
|
|
|
|
|
userId,
|
|
|
|
|
guildId
|
|
|
|
|
);
|
2025-10-09 00:12:56 -05:00
|
|
|
}
|
|
|
|
|
combatSummary = {
|
|
|
|
|
mobs: mobLogs,
|
|
|
|
|
totalDamageDealt: totalDealt,
|
|
|
|
|
totalDamageTaken: totalTaken,
|
2025-10-09 00:44:30 -05:00
|
|
|
mobsDefeated: totalMobsDefeated,
|
|
|
|
|
victory,
|
|
|
|
|
playerStartHp: startHp,
|
|
|
|
|
playerEndHp: endHp,
|
|
|
|
|
outcome: victory ? "victory" : "defeat",
|
2025-10-08 23:18:34 -05:00
|
|
|
};
|
2025-10-05 00:51:16 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Registrar la ejecución
|
|
|
|
|
const resultJson: Prisma.InputJsonValue = {
|
|
|
|
|
rewards: delivered,
|
|
|
|
|
mobs: mobsSpawned,
|
|
|
|
|
tool: toolInfo,
|
2025-10-09 00:12:56 -05:00
|
|
|
combat: combatSummary,
|
2025-10-08 23:18:34 -05:00
|
|
|
notes: "auto",
|
2025-10-05 00:51:16 -05:00
|
|
|
} as unknown as Prisma.InputJsonValue;
|
|
|
|
|
|
|
|
|
|
await prisma.minigameRun.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId,
|
|
|
|
|
guildId,
|
|
|
|
|
areaId: area.id,
|
|
|
|
|
level,
|
|
|
|
|
toolItemId: null, // opcional si decides guardar id del item herramienta
|
|
|
|
|
success: true,
|
|
|
|
|
result: resultJson,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Progreso del jugador
|
|
|
|
|
await prisma.playerProgress.upsert({
|
|
|
|
|
where: { userId_guildId_areaId: { userId, guildId, areaId: area.id } },
|
2025-10-08 23:18:34 -05:00
|
|
|
create: {
|
|
|
|
|
userId,
|
|
|
|
|
guildId,
|
|
|
|
|
areaId: area.id,
|
|
|
|
|
highestLevel: Math.max(1, level),
|
|
|
|
|
},
|
2025-10-05 00:51:16 -05:00
|
|
|
update: { highestLevel: { set: level } },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Setear cooldown
|
|
|
|
|
if (cdSeconds > 0) {
|
|
|
|
|
await prisma.actionCooldown.upsert({
|
|
|
|
|
where: { userId_guildId_key: { userId, guildId, key: cdKey } },
|
|
|
|
|
update: { until: new Date(Date.now() + cdSeconds * 1000) },
|
2025-10-08 23:18:34 -05:00
|
|
|
create: {
|
|
|
|
|
userId,
|
|
|
|
|
guildId,
|
|
|
|
|
key: cdKey,
|
|
|
|
|
until: new Date(Date.now() + cdSeconds * 1000),
|
|
|
|
|
},
|
2025-10-05 00:51:16 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 23:18:34 -05:00
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
rewards: delivered,
|
|
|
|
|
mobs: mobsSpawned,
|
|
|
|
|
tool: toolInfo,
|
2025-10-09 00:12:56 -05:00
|
|
|
combat: combatSummary,
|
2025-10-08 23:18:34 -05:00
|
|
|
};
|
2025-10-05 00:51:16 -05:00
|
|
|
}
|
2025-10-05 02:08:59 -05:00
|
|
|
|
|
|
|
|
// Convenience wrappers with auto-level (from PlayerProgress) and auto-tool selection inside validateRequirements
|
2025-10-08 23:18:34 -05:00
|
|
|
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,
|
|
|
|
|
});
|
2025-10-05 02:08:59 -05:00
|
|
|
}
|
|
|
|
|
|
2025-10-08 23:18:34 -05:00
|
|
|
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,
|
|
|
|
|
});
|
2025-10-05 02:08:59 -05:00
|
|
|
}
|