From ae12b50aa174ac381b6e3df90cf3c95fc0507d9b Mon Sep 17 00:00:00 2001 From: shni Date: Thu, 9 Oct 2025 01:26:29 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20agregar=20registro=20de=20muertes=20y?= =?UTF-8?q?=20penalizaciones=20din=C3=A1micas=20en=20minijuegos;=20impleme?= =?UTF-8?q?ntar=20comandos=20para=20gestionar=20efectos=20de=20estado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 38 ++++++++- src/commands/messages/game/effects.ts | 104 ++++++++++++++++++++++++ src/commands/messages/game/player.ts | 12 ++- src/game/combat/equipmentService.ts | 31 +++++-- src/game/combat/statusEffectsService.ts | 14 ++++ src/game/minigames/service.ts | 67 ++++++++++++++- src/game/minigames/types.ts | 1 + 7 files changed, 251 insertions(+), 16 deletions(-) create mode 100644 src/commands/messages/game/effects.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 820fc4f..d381e0b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,6 +63,7 @@ model Guild { PlayerStreak PlayerStreak[] AuditLog AuditLog[] PlayerStatusEffect PlayerStatusEffect[] + DeathLog DeathLog[] } /** @@ -99,6 +100,7 @@ model User { PlayerStreak PlayerStreak[] AuditLog AuditLog[] PlayerStatusEffect PlayerStatusEffect[] + DeathLog DeathLog[] } /** @@ -527,9 +529,10 @@ model GameArea { config Json? metadata Json? - levels GameAreaLevel[] - runs MinigameRun[] - progress PlayerProgress[] + levels GameAreaLevel[] + runs MinigameRun[] + progress PlayerProgress[] + deathLogs DeathLog[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1020,3 +1023,32 @@ model AuditLog { @@index([action]) @@index([createdAt]) } + +/** + * ----------------------------------------------------------------------------- + * Log de Muertes (DeathLog) + * ----------------------------------------------------------------------------- + * Auditoría de penalizaciones al morir para trazabilidad y balance. + */ +model DeathLog { + id String @id @default(cuid()) + userId String + guildId String + areaId String? + areaKey String? + level Int? + goldLost Int @default(0) + percentApplied Float @default(0) // porcentaje calculado de penalización + autoDefeatNoWeapon Boolean @default(false) + fatigueMagnitude Float? // 0.15 = 15% + fatigueMinutes Int? // minutos aplicados + metadata Json? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + guild Guild @relation(fields: [guildId], references: [id]) + area GameArea? @relation(fields: [areaId], references: [id]) + + @@index([userId, guildId]) + @@index([createdAt]) +} diff --git a/src/commands/messages/game/effects.ts b/src/commands/messages/game/effects.ts new file mode 100644 index 0000000..0350e03 --- /dev/null +++ b/src/commands/messages/game/effects.ts @@ -0,0 +1,104 @@ +import type { CommandMessage } from "../../../core/types/commands"; +import type Amayo from "../../../core/client"; +import { + getActiveStatusEffects, + removeStatusEffect, + clearAllStatusEffects, +} from "../../../game/combat/statusEffectsService"; +import { consumeItemByKey } from "../../../game/economy/service"; + +// Item key que permite purgar efectos. Configurable más adelante. +const PURGE_ITEM_KEY = "potion.purga"; // placeholder + +export const command: CommandMessage = { + name: "efectos", + aliases: ["effects"], + type: "message", + cooldown: 5, + category: "Economía", + description: + "Lista tus efectos de estado activos y permite purgarlos con un ítem de purga.", + usage: "efectos [purgar|remover |todo]", + run: async (message, args, _client: Amayo) => { + const userId = message.author.id; + const guildId = message.guild!.id; + const sub = (args[0] || "").toLowerCase(); + + if ( + sub === "purgar" || + sub === "purga" || + sub === "remover" || + sub === "remove" || + sub === "todo" + ) { + // Requiere el item de purga + try { + const consume = await consumeItemByKey( + userId, + guildId, + PURGE_ITEM_KEY, + 1 + ); + if (!consume.consumed) { + await message.reply( + `Necesitas 1 **${PURGE_ITEM_KEY}** en tu inventario para purgar efectos.` + ); + return; + } + } catch { + await message.reply( + `No se pudo consumir el ítem de purga (${PURGE_ITEM_KEY}). Asegúrate de que existe.` + ); + return; + } + + // Modo remover tipo específico: efectos remover + if (sub === "remover" || sub === "remove") { + const typeArg = args[1]; + if (!typeArg) { + await message.reply("Debes indicar el tipo: efectos remover FATIGUE"); + return; + } + await removeStatusEffect(userId, guildId, typeArg.toUpperCase()); + await message.reply(`Efecto **${typeArg.toUpperCase()}** eliminado.`); + return; + } + + // Modo todo + if (sub === "todo" || sub === "purgar" || sub === "purga") { + await clearAllStatusEffects(userId, guildId); + await message.reply("Todos los efectos han sido purgados."); + return; + } + } + + // Listar efectos + const effects = await getActiveStatusEffects(userId, guildId); + if (!effects.length) { + await message.reply("No tienes efectos activos."); + return; + } + + const now = Date.now(); + const lines = effects.map((e) => { + let remain = "permanente"; + if (e.expiresAt) { + const ms = e.expiresAt.getTime() - now; + if (ms > 0) { + const m = Math.floor(ms / 60000); + const s = Math.floor((ms % 60000) / 1000); + remain = `${m}m ${s}s`; + } else remain = "exp"; + } + const pct = e.magnitude ? ` (${Math.round(e.magnitude * 100)}%)` : ""; + return `• ${e.type}${pct} - ${remain}`; + }); + + await message.reply( + `**Efectos Activos:**\n${lines.join( + "\n" + )}\n\nUsa: efectos purgar | efectos remover | efectos todo (requiere ${PURGE_ITEM_KEY}).` + ); + return; + }, +}; diff --git a/src/commands/messages/game/player.ts b/src/commands/messages/game/player.ts index c30250c..beaf7b9 100644 --- a/src/commands/messages/game/player.ts +++ b/src/commands/messages/game/player.ts @@ -33,6 +33,14 @@ export const command: CommandMessage = { const wallet = await getOrCreateWallet(userId, guildId); const { eq, weapon, armor, cape } = await getEquipment(userId, guildId); const stats = await getEffectiveStats(userId, guildId); + const showDefense = + stats.baseDefense != null && stats.baseDefense !== stats.defense + ? `${stats.defense} (_${stats.baseDefense}_ base)` + : `${stats.defense}`; + const showDamage = + stats.baseDamage != null && stats.baseDamage !== stats.damage + ? `${stats.damage} (_${stats.baseDamage}_ base)` + : `${stats.damage}`; const playerStats = await getPlayerStatsFormatted(userId, guildId); const rawStats = await getOrCreatePlayerStats(userId, guildId); const streak = rawStats.currentWinStreak; @@ -101,8 +109,8 @@ export const command: CommandMessage = { `<:healbonus:1425671499792121877> HP: **${stats.hp}/${ stats.maxHp }** ${heartsBar(stats.hp, stats.maxHp)}\n` + - `<:damage:1425670476449189998> ATK: **${stats.damage}** ${damageBonusDisplay}\n` + - `<:defens:1425670433910427862> DEF: **${stats.defense}**\n` + + `<:damage:1425670476449189998> ATK: **${showDamage}** ${damageBonusDisplay}\n` + + `<:defens:1425670433910427862> DEF: **${showDefense}**\n` + `🏆 Racha: **${streak}** (mejor: ${rawStats.longestWinStreak})\n` + ` Monedas: **${wallet.coins.toLocaleString()}**`, }, diff --git a/src/game/combat/equipmentService.ts b/src/game/combat/equipmentService.ts index 93fb136..136dbc9 100644 --- a/src/game/combat/equipmentService.ts +++ b/src/game/combat/equipmentService.ts @@ -66,10 +66,12 @@ export async function setEquipmentSlot( } export type EffectiveStats = { - damage: number; - defense: number; + damage: number; // daño efectivo (con racha + efectos) + defense: number; // defensa efectiva (con efectos) maxHp: number; hp: number; + baseDamage?: number; // daño base antes de status effects + baseDefense?: number; // defensa base antes de status effects }; async function getMutationBonuses( @@ -113,7 +115,7 @@ export async function getEffectiveStats( const mutC = await getMutationBonuses(userId, guildId, cape?.id ?? null); let damage = Math.max(0, (w.damage ?? 0) + mutW.damageBonus); - const defense = Math.max(0, (a.defense ?? 0) + mutA.defenseBonus); + const defenseBase = Math.max(0, (a.defense ?? 0) + mutA.defenseBonus); const maxHp = Math.max( 1, state.maxHp + (c.maxHpBonus ?? 0) + mutC.maxHpBonus @@ -141,18 +143,33 @@ export async function getEffectiveStats( const { damageMultiplier, defenseMultiplier } = computeDerivedModifiers( effects.map((e) => ({ type: e.type, magnitude: e.magnitude })) ); + const baseDamage = damage; + const baseDefense = defenseBase; damage = Math.max(0, Math.round(damage * damageMultiplier)); - // defensa se recalcula localmente (no mutamos const original para claridad) const adjustedDefense = Math.max( 0, - Math.round(defense * defenseMultiplier) + Math.round(defenseBase * defenseMultiplier) ); - return { damage, defense: adjustedDefense, maxHp, hp }; + return { + damage, + defense: adjustedDefense, + maxHp, + hp, + baseDamage, + baseDefense, + }; } } catch { // silencioso } - return { damage, defense, maxHp, hp }; + return { + damage, + defense: defenseBase, + maxHp, + hp, + baseDamage: damage, + baseDefense: defenseBase, + }; } export async function adjustHP(userId: string, guildId: string, delta: number) { diff --git a/src/game/combat/statusEffectsService.ts b/src/game/combat/statusEffectsService.ts index 440386a..5e3f7ad 100644 --- a/src/game/combat/statusEffectsService.ts +++ b/src/game/combat/statusEffectsService.ts @@ -75,3 +75,17 @@ export async function applyDeathFatigue( data: { reason: "death" }, }); } + +export async function removeStatusEffect( + userId: string, + guildId: string, + type: StatusEffectType +) { + await prisma.playerStatusEffect.deleteMany({ + where: { userId, guildId, type }, + }); +} + +export async function clearAllStatusEffects(userId: string, guildId: string) { + await prisma.playerStatusEffect.deleteMany({ where: { userId, guildId } }); +} diff --git a/src/game/minigames/service.ts b/src/game/minigames/service.ts index 2c2258e..6a589d2 100644 --- a/src/game/minigames/service.ts +++ b/src/game/minigames/service.ts @@ -25,6 +25,25 @@ import type { } from "./types"; import type { Prisma } from "@prisma/client"; +// Escalado dinámico de penalización por derrota según área/nivel y riesgo. +// Se puede ampliar leyendo area.metadata.riskFactor (0-3) y level. +function computeDeathPenaltyPercent( + area: { key: string; metadata: any }, + level: number +): number { + const meta = (area.metadata as any) || {}; + const base = 0.05; // 5% base + const risk = + typeof meta.riskFactor === "number" + ? Math.max(0, Math.min(3, meta.riskFactor)) + : 0; + const levelBoost = Math.min(0.1, Math.max(0, (level - 1) * 0.005)); // +0.5% por nivel adicional hasta +10% + const riskBoost = risk * 0.02; // cada punto riesgo +2% + let pct = base + levelBoost + riskBoost; + if (pct > 0.25) pct = 0.25; // cap 25% + return pct; // ej: 0.08 = 8% +} + // Auto-select best tool from inventory by type and constraints async function findBestToolKey( userId: string, @@ -418,11 +437,13 @@ export async function runMinigame( try { const wallet = await getOrCreateWallet(userId, guildId); const coins = wallet.coins; + const percent = computeDeathPenaltyPercent(area, level); let goldLost = 0; if (coins > 0) { - goldLost = Math.floor(coins * 0.05); // 5% + goldLost = Math.floor(coins * percent); if (goldLost < 1) goldLost = 1; - if (goldLost > 500) goldLost = 500; // cap + if (goldLost > 5000) goldLost = 5000; // nuevo cap más alto por riesgo escalado + if (goldLost > coins) goldLost = coins; // no perder más de lo que tienes if (goldLost > 0) { await prisma.economyWallet.update({ where: { userId_guildId: { userId, guildId } }, @@ -442,7 +463,25 @@ export async function runMinigame( goldLost, fatigueAppliedMinutes: fatigueMinutes, fatigueMagnitude, + percentApplied: percent, }; + try { + await prisma.deathLog.create({ + data: { + userId, + guildId, + areaId: area.id, + areaKey: area.key, + level, + goldLost: goldLost || 0, + percentApplied: percent, + autoDefeatNoWeapon: true, + fatigueMagnitude, + fatigueMinutes, + metadata: {}, + }, + }); + } catch {} combatSummary = { mobs: mobLogs, totalDamageDealt: 0, @@ -579,11 +618,13 @@ export async function runMinigame( try { const wallet = await getOrCreateWallet(userId, guildId); const coins = wallet.coins; + const percent = computeDeathPenaltyPercent(area, level); let goldLost = 0; if (coins > 0) { - goldLost = Math.floor(coins * 0.05); + goldLost = Math.floor(coins * percent); if (goldLost < 1) goldLost = 1; - if (goldLost > 500) goldLost = 500; + if (goldLost > 5000) goldLost = 5000; + if (goldLost > coins) goldLost = coins; if (goldLost > 0) { await prisma.economyWallet.update({ where: { userId_guildId: { userId, guildId } }, @@ -603,7 +644,25 @@ export async function runMinigame( goldLost, fatigueAppliedMinutes: fatigueMinutes, fatigueMagnitude, + percentApplied: percent, }; + try { + await prisma.deathLog.create({ + data: { + userId, + guildId, + areaId: area.id, + areaKey: area.key, + level, + goldLost: goldLost || 0, + percentApplied: percent, + autoDefeatNoWeapon: false, + fatigueMagnitude, + fatigueMinutes, + metadata: { mobs: totalMobsDefeated }, + }, + }); + } catch {} } catch { // silencioso } diff --git a/src/game/minigames/types.ts b/src/game/minigames/types.ts index b4729f1..b249c6f 100644 --- a/src/game/minigames/types.ts +++ b/src/game/minigames/types.ts @@ -97,5 +97,6 @@ export type CombatSummary = { goldLost?: number; fatigueAppliedMinutes?: number; fatigueMagnitude?: number; // 0.15 = 15% + percentApplied?: number; // porcentaje calculado dinámicamente según área/nivel }; };