From b5701df7ae7491f81ea0d172ace360580f3b3286 Mon Sep 17 00:00:00 2001 From: shni Date: Thu, 9 Oct 2025 01:01:12 -0500 Subject: [PATCH] feat: implementar sistema de efectos de estado con persistencia en la base de datos --- prisma/schema.prisma | 207 ++++++++++-------- src/game/combat/equipmentService.ts | 22 ++ src/game/combat/statusEffectsService.ts | 77 +++++++ src/game/lib/rpgFormat.ts | 6 + src/game/minigames/service.ts | 275 ++++++++++++++---------- src/game/minigames/types.ts | 1 + 6 files changed, 381 insertions(+), 207 deletions(-) create mode 100644 src/game/combat/statusEffectsService.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 162ddff..820fc4f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,13 +9,11 @@ generator client { } datasource db { - provider = "postgresql" - url = env("XATA_DB") + provider = "postgresql" + url = env("XATA_DB") shadowDatabaseUrl = env("XATA_SHADOW_DB") } - - /** * ----------------------------------------------------------------------------- * Modelo para el Servidor (Guild) @@ -57,13 +55,14 @@ model Guild { ScheduledMobAttack ScheduledMobAttack[] // Nuevas relaciones para sistemas de engagement - Achievement Achievement[] - PlayerAchievement PlayerAchievement[] - Quest Quest[] - QuestProgress QuestProgress[] - PlayerStats PlayerStats[] - PlayerStreak PlayerStreak[] - AuditLog AuditLog[] + Achievement Achievement[] + PlayerAchievement PlayerAchievement[] + Quest Quest[] + QuestProgress QuestProgress[] + PlayerStats PlayerStats[] + PlayerStreak PlayerStreak[] + AuditLog AuditLog[] + PlayerStatusEffect PlayerStatusEffect[] } /** @@ -94,11 +93,12 @@ model User { ScheduledMobAttack ScheduledMobAttack[] // Nuevas relaciones para sistemas de engagement - PlayerAchievement PlayerAchievement[] - QuestProgress QuestProgress[] - PlayerStats PlayerStats[] - PlayerStreak PlayerStreak[] - AuditLog AuditLog[] + PlayerAchievement PlayerAchievement[] + QuestProgress QuestProgress[] + PlayerStats PlayerStats[] + PlayerStreak PlayerStreak[] + AuditLog AuditLog[] + PlayerStatusEffect PlayerStatusEffect[] } /** @@ -769,27 +769,27 @@ model ScheduledMobAttack { * ----------------------------------------------------------------------------- */ model Achievement { - id String @id @default(cuid()) + id String @id @default(cuid()) key String name String description String icon String? - category String // "mining", "crafting", "combat", "economy", "exploration" + category String // "mining", "crafting", "combat", "economy", "exploration" // Requisitos para desbloquear (JSON flexible) - requirements Json // { type: "mine_count", value: 100 } + requirements Json // { type: "mine_count", value: 100 } // Recompensas al desbloquear - rewards Json? // { coins: 500, items: [...], title: "..." } + rewards Json? // { coins: 500, items: [...], title: "..." } - guildId String? - guild Guild? @relation(fields: [guildId], references: [id]) + guildId String? + guild Guild? @relation(fields: [guildId], references: [id]) // Logros desbloqueados por usuarios - unlocked PlayerAchievement[] + unlocked PlayerAchievement[] - hidden Boolean @default(false) // logros secretos - points Int @default(10) // puntos que otorga el logro + hidden Boolean @default(false) // logros secretos + points Int @default(10) // puntos que otorga el logro createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -799,7 +799,7 @@ model Achievement { } model PlayerAchievement { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String guildId String achievementId String @@ -808,10 +808,10 @@ model PlayerAchievement { guild Guild @relation(fields: [guildId], references: [id]) achievement Achievement @relation(fields: [achievementId], references: [id]) - progress Int @default(0) // progreso actual hacia el logro - unlockedAt DateTime? // null si aún no está desbloqueado - notified Boolean @default(false) // si ya se notificó al usuario - metadata Json? + progress Int @default(0) // progreso actual hacia el logro + unlockedAt DateTime? // null si aún no está desbloqueado + notified Boolean @default(false) // si ya se notificó al usuario + metadata Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -826,33 +826,33 @@ model PlayerAchievement { * ----------------------------------------------------------------------------- */ model Quest { - id String @id @default(cuid()) + id String @id @default(cuid()) key String name String description String icon String? // Tipo de misión - type String // "daily", "weekly", "event", "permanent" - category String // "mining", "combat", "economy", "exploration" + type String // "daily", "weekly", "event", "permanent" + category String // "mining", "combat", "economy", "exploration" // Requisitos - requirements Json // { type: "mine", count: 10 } + requirements Json // { type: "mine", count: 10 } // Recompensas - rewards Json // { coins: 500, items: [...], xp: 100 } + rewards Json // { coins: 500, items: [...], xp: 100 } // Disponibilidad - startAt DateTime? - endAt DateTime? + startAt DateTime? + endAt DateTime? - guildId String? - guild Guild? @relation(fields: [guildId], references: [id]) + guildId String? + guild Guild? @relation(fields: [guildId], references: [id]) - progress QuestProgress[] + progress QuestProgress[] - active Boolean @default(true) - repeatable Boolean @default(false) // si se puede repetir después de completar + active Boolean @default(true) + repeatable Boolean @default(false) // si se puede repetir después de completar createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -863,18 +863,18 @@ model Quest { } model QuestProgress { - id String @id @default(cuid()) - userId String - guildId String - questId String + id String @id @default(cuid()) + userId String + guildId String + questId String - progress Int @default(0) // progreso actual - completed Boolean @default(false) - claimed Boolean @default(false) // si ya reclamó recompensa + progress Int @default(0) // progreso actual + completed Boolean @default(false) + claimed Boolean @default(false) // si ya reclamó recompensa - user User @relation(fields: [userId], references: [id]) - guild Guild @relation(fields: [guildId], references: [id]) - quest Quest @relation(fields: [questId], references: [id]) + user User @relation(fields: [userId], references: [id]) + guild Guild @relation(fields: [guildId], references: [id]) + quest Quest @relation(fields: [questId], references: [id]) completedAt DateTime? claimedAt DateTime? @@ -900,34 +900,34 @@ model PlayerStats { guildId String // Stats de minijuegos - minesCompleted Int @default(0) - fishingCompleted Int @default(0) - fightsCompleted Int @default(0) - farmsCompleted Int @default(0) + minesCompleted Int @default(0) + fishingCompleted Int @default(0) + fightsCompleted Int @default(0) + farmsCompleted Int @default(0) // Stats de combate - mobsDefeated Int @default(0) - damageDealt Int @default(0) - damageTaken Int @default(0) - timesDefeated Int @default(0) + mobsDefeated Int @default(0) + damageDealt Int @default(0) + damageTaken Int @default(0) + timesDefeated Int @default(0) // Stats de economía - totalCoinsEarned Int @default(0) - totalCoinsSpent Int @default(0) - itemsCrafted Int @default(0) - itemsSmelted Int @default(0) - itemsPurchased Int @default(0) + totalCoinsEarned Int @default(0) + totalCoinsSpent Int @default(0) + itemsCrafted Int @default(0) + itemsSmelted Int @default(0) + itemsPurchased Int @default(0) // Stats de items - chestsOpened Int @default(0) - itemsConsumed Int @default(0) - itemsEquipped Int @default(0) + chestsOpened Int @default(0) + itemsConsumed Int @default(0) + itemsEquipped Int @default(0) // Récords personales - highestDamageDealt Int @default(0) - longestWinStreak Int @default(0) - currentWinStreak Int @default(0) - mostCoinsAtOnce Int @default(0) + highestDamageDealt Int @default(0) + longestWinStreak Int @default(0) + currentWinStreak Int @default(0) + mostCoinsAtOnce Int @default(0) user User @relation(fields: [userId], references: [id]) guild Guild @relation(fields: [guildId], references: [id]) @@ -939,23 +939,54 @@ model PlayerStats { @@index([userId, guildId]) } +/** + * ----------------------------------------------------------------------------- + * Efectos de Estado del Jugador (Status Effects) + * ----------------------------------------------------------------------------- + * Almacena efectos temporales como FATIGUE (reduce daño/defensa), BLEED, BUFFS, etc. + * type: clave tipo string flexible (ej: "FATIGUE", "BLESSING", "POISON") + * stacking: se puede permitir múltiples efectos del mismo tipo si cambias la unique compuesta. + */ +model PlayerStatusEffect { + id String @id @default(cuid()) + userId String + guildId String + type String + // magnitud genérica (ej: 0.15 para 15%); interpretación depende del tipo + magnitude Float @default(0) + // duración controlada por expiresAt; si null = permanente hasta eliminación manual + expiresAt DateTime? + data Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + guild Guild @relation(fields: [guildId], references: [id]) + + // Un efecto único por tipo (puedes quitar esta línea si quieres stackeables): + @@unique([userId, guildId, type]) + @@index([userId, guildId]) + @@index([guildId]) + @@index([expiresAt]) +} + /** * ----------------------------------------------------------------------------- * Sistema de Rachas (Streaks) * ----------------------------------------------------------------------------- */ model PlayerStreak { - id String @id @default(cuid()) - userId String - guildId String + id String @id @default(cuid()) + userId String + guildId String - currentStreak Int @default(0) - longestStreak Int @default(0) - lastActiveDate DateTime @default(now()) - totalDaysActive Int @default(0) + currentStreak Int @default(0) + longestStreak Int @default(0) + lastActiveDate DateTime @default(now()) + totalDaysActive Int @default(0) // Recompensas reclamadas por día - rewardsClaimed Json? // { day3: true, day7: true, etc } + rewardsClaimed Json? // { day3: true, day7: true, etc } user User @relation(fields: [userId], references: [id]) guild Guild @relation(fields: [guildId], references: [id]) @@ -973,15 +1004,15 @@ model PlayerStreak { * ----------------------------------------------------------------------------- */ model AuditLog { - id String @id @default(cuid()) - userId String - guildId String - action String // "buy", "craft", "trade", "equip", "mine", "fight", etc. - target String? // ID del item/mob/área afectado - details Json? // detalles adicionales + id String @id @default(cuid()) + userId String + guildId String + action String // "buy", "craft", "trade", "equip", "mine", "fight", etc. + target String? // ID del item/mob/área afectado + details Json? // detalles adicionales - user User @relation(fields: [userId], references: [id]) - guild Guild @relation(fields: [guildId], references: [id]) + user User @relation(fields: [userId], references: [id]) + guild Guild @relation(fields: [guildId], references: [id]) createdAt DateTime @default(now()) diff --git a/src/game/combat/equipmentService.ts b/src/game/combat/equipmentService.ts index eb5cea9..93fb136 100644 --- a/src/game/combat/equipmentService.ts +++ b/src/game/combat/equipmentService.ts @@ -1,4 +1,8 @@ import { prisma } from "../../core/database/prisma"; +import { + getActiveStatusEffects, + computeDerivedModifiers, +} from "./statusEffectsService"; import type { ItemProps } from "../economy/types"; import { ensureUserAndGuildExist } from "../core/userService"; @@ -130,6 +134,24 @@ export async function getEffectiveStats( } catch { // silencioso: si falla stats no bloquea } + // Aplicar efectos de estado activos (FATIGUE etc.) + try { + const effects = await getActiveStatusEffects(userId, guildId); + if (effects.length) { + const { damageMultiplier, defenseMultiplier } = computeDerivedModifiers( + effects.map((e) => ({ type: e.type, magnitude: e.magnitude })) + ); + 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) + ); + return { damage, defense: adjustedDefense, maxHp, hp }; + } + } catch { + // silencioso + } return { damage, defense, maxHp, hp }; } diff --git a/src/game/combat/statusEffectsService.ts b/src/game/combat/statusEffectsService.ts new file mode 100644 index 0000000..440386a --- /dev/null +++ b/src/game/combat/statusEffectsService.ts @@ -0,0 +1,77 @@ +import { prisma } from "../../core/database/prisma"; + +export type StatusEffectType = "FATIGUE" | string; + +export interface StatusEffectOptions { + magnitude?: number; // porcentaje o valor genérico según tipo + durationMs?: number; // duración; si no se pasa => permanente + data?: Record; +} + +export async function applyStatusEffect( + userId: string, + guildId: string, + type: StatusEffectType, + opts?: StatusEffectOptions +) { + const now = Date.now(); + const expiresAt = opts?.durationMs ? new Date(now + opts.durationMs) : null; + return prisma.playerStatusEffect.upsert({ + where: { userId_guildId_type: { userId, guildId, type } }, + update: { + magnitude: opts?.magnitude ?? 0, + expiresAt, + data: opts?.data ?? {}, + }, + create: { + userId, + guildId, + type, + magnitude: opts?.magnitude ?? 0, + expiresAt, + data: opts?.data ?? {}, + }, + }); +} + +export async function getActiveStatusEffects(userId: string, guildId: string) { + // Limpieza perezosa de expirados + await prisma.playerStatusEffect.deleteMany({ + where: { userId, guildId, expiresAt: { lt: new Date() } }, + }); + return prisma.playerStatusEffect.findMany({ + where: { userId, guildId }, + }); +} + +export function computeDerivedModifiers( + effects: { type: string; magnitude: number }[] +) { + let damageMultiplier = 1; + let defenseMultiplier = 1; + for (const e of effects) { + switch (e.type) { + case "FATIGUE": + // Reducción lineal: magnitude = 0.15 => -15% daño y -10% defensa, configurable + damageMultiplier *= 1 - Math.min(0.9, e.magnitude); // cap 90% reducción + defenseMultiplier *= 1 - Math.min(0.9, e.magnitude * 0.66); + break; + default: + break; // otros efectos futuros + } + } + return { damageMultiplier, defenseMultiplier }; +} + +export async function applyDeathFatigue( + userId: string, + guildId: string, + magnitude = 0.15, + minutes = 5 +) { + return applyStatusEffect(userId, guildId, "FATIGUE", { + magnitude, + durationMs: minutes * 60 * 1000, + data: { reason: "death" }, + }); +} diff --git a/src/game/lib/rpgFormat.ts b/src/game/lib/rpgFormat.ts index becf2aa..7f37689 100644 --- a/src/game/lib/rpgFormat.ts +++ b/src/game/lib/rpgFormat.ts @@ -82,12 +82,18 @@ export function combatSummaryRPG(c: { playerEndHp?: number | null; outcome?: "victory" | "defeat"; maxRefHp?: number; // para cálculo visual si difiere + autoDefeatNoWeapon?: boolean; }) { const header = `**Combate (${outcomeLabel(c.outcome)})**`; const lines = [ `• Mobs: ${c.mobs} | Derrotados: ${c.mobsDefeated}/${c.mobs}`, `• Daño hecho: ${c.totalDamageDealt} | Daño recibido: ${c.totalDamageTaken}`, ]; + if (c.autoDefeatNoWeapon) { + lines.push( + `• Derrota automática: no tenías arma equipada o válida (daño 0). Equipa un arma para poder atacar.` + ); + } if (c.playerStartHp != null && c.playerEndHp != null) { const maxHp = c.maxRefHp || Math.max(c.playerStartHp, c.playerEndHp); lines.push( diff --git a/src/game/minigames/service.ts b/src/game/minigames/service.ts index dbe4e4b..50d9e28 100644 --- a/src/game/minigames/service.ts +++ b/src/game/minigames/service.ts @@ -387,130 +387,167 @@ export async function runMinigame( const eff = await getEffectiveStats(userId, guildId); const playerState = await ensurePlayerState(userId, guildId); const startHp = eff.hp; // HP actual persistente - let currentHp = startHp; - const mobLogs: CombatSummary["mobs"] = []; - let totalDealt = 0; - let totalTaken = 0; - 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; - }; - for (const mobKey of mobsSpawned) { - 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; - totalDealt += playerDamage; - let playerTaken = 0; - 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; - } - } - rounds.push({ - mobKey, - round, - playerDamageDealt: playerDamage, - playerDamageTaken: playerTaken, - mobRemainingHp: Math.max(0, mobHp), - mobDefeated: mobHp <= 0, - }); - if (mobHp <= 0) { - totalMobsDefeated++; - break; - } - if (currentHp <= 0) break; - round++; - } - mobLogs.push({ - mobKey, - maxHp: mobBaseHp, - defeated: mobHp <= 0, - totalDamageDealt: mobDamageDealt, - totalDamageTakenFromMob: mobDamageTakenFromMob, - rounds, - }); - 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 = {}; - 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 + // Regla: si el jugador no tiene arma (damage <=0) no puede infligir daño real y perderá automáticamente contra cualquier mob. + // En lugar de simular rondas irreales con daño mínimo artificial, forzamos derrota directa manteniendo coherencia. + if (!eff.damage || eff.damage <= 0) { + // Registrar derrota simple contra la lista de mobs (no se derrotan mobs). + const mobLogs: CombatSummary["mobs"] = mobsSpawned.map((mk) => ({ + mobKey: mk, + maxHp: 0, + defeated: false, + totalDamageDealt: 0, + totalDamageTakenFromMob: 0, + rounds: [], + })); + // Aplicar daño simulado: mobs atacan una vez (opcional). Aquí asumimos que el jugador cae a 0 directamente para simplificar. + const endHp = Math.max(1, Math.floor(eff.maxHp * 0.5)); + await adjustHP(userId, guildId, endHp - playerState.hp); // regen al 50% + await updateStats(userId, guildId, { + damageTaken: 0, // opcional: podría ponerse un valor fijo si quieres penalizar + timesDefeated: 1, + } as any); + // Reset de racha si existía 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 - ); + combatSummary = { + mobs: mobLogs, + totalDamageDealt: 0, + totalDamageTaken: 0, + mobsDefeated: 0, + victory: false, + playerStartHp: startHp, + playerEndHp: endHp, + outcome: "defeat", + autoDefeatNoWeapon: true, + }; + } else { + let currentHp = startHp; + const mobLogs: CombatSummary["mobs"] = []; + let totalDealt = 0; + let totalTaken = 0; + 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; + }; + for (const mobKey of mobsSpawned) { + 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; + totalDealt += playerDamage; + let playerTaken = 0; + 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; + } + } + rounds.push({ + mobKey, + round, + playerDamageDealt: playerDamage, + playerDamageTaken: playerTaken, + mobRemainingHp: Math.max(0, mobHp), + mobDefeated: mobHp <= 0, + }); + if (mobHp <= 0) { + totalMobsDefeated++; + break; + } + if (currentHp <= 0) break; + round++; + } + mobLogs.push({ + mobKey, + maxHp: mobBaseHp, + defeated: mobHp <= 0, + totalDamageDealt: mobDamageDealt, + totalDamageTakenFromMob: mobDamageTakenFromMob, + rounds, + }); + 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 = {}; + 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 + ); + } + combatSummary = { + mobs: mobLogs, + totalDamageDealt: totalDealt, + totalDamageTaken: totalTaken, + mobsDefeated: totalMobsDefeated, + victory, + playerStartHp: startHp, + playerEndHp: endHp, + outcome: victory ? "victory" : "defeat", + }; } - combatSummary = { - mobs: mobLogs, - totalDamageDealt: totalDealt, - totalDamageTaken: totalTaken, - mobsDefeated: totalMobsDefeated, - victory, - playerStartHp: startHp, - playerEndHp: endHp, - outcome: victory ? "victory" : "defeat", - }; } // Registrar la ejecución diff --git a/src/game/minigames/types.ts b/src/game/minigames/types.ts index 11ca75f..e454327 100644 --- a/src/game/minigames/types.ts +++ b/src/game/minigames/types.ts @@ -92,4 +92,5 @@ export type CombatSummary = { playerStartHp?: number; playerEndHp?: number; outcome?: "victory" | "defeat"; + autoDefeatNoWeapon?: boolean; // true si la derrota fue inmediata por no tener arma (damage <= 0) };