feat: implementar sistema de efectos de estado con persistencia en la base de datos

This commit is contained in:
2025-10-09 01:01:12 -05:00
parent 2befd4a278
commit b5701df7ae
6 changed files with 381 additions and 207 deletions

View File

@@ -9,13 +9,11 @@ generator client {
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("XATA_DB") url = env("XATA_DB")
shadowDatabaseUrl = env("XATA_SHADOW_DB") shadowDatabaseUrl = env("XATA_SHADOW_DB")
} }
/** /**
* ----------------------------------------------------------------------------- * -----------------------------------------------------------------------------
* Modelo para el Servidor (Guild) * Modelo para el Servidor (Guild)
@@ -57,13 +55,14 @@ model Guild {
ScheduledMobAttack ScheduledMobAttack[] ScheduledMobAttack ScheduledMobAttack[]
// Nuevas relaciones para sistemas de engagement // Nuevas relaciones para sistemas de engagement
Achievement Achievement[] Achievement Achievement[]
PlayerAchievement PlayerAchievement[] PlayerAchievement PlayerAchievement[]
Quest Quest[] Quest Quest[]
QuestProgress QuestProgress[] QuestProgress QuestProgress[]
PlayerStats PlayerStats[] PlayerStats PlayerStats[]
PlayerStreak PlayerStreak[] PlayerStreak PlayerStreak[]
AuditLog AuditLog[] AuditLog AuditLog[]
PlayerStatusEffect PlayerStatusEffect[]
} }
/** /**
@@ -94,11 +93,12 @@ model User {
ScheduledMobAttack ScheduledMobAttack[] ScheduledMobAttack ScheduledMobAttack[]
// Nuevas relaciones para sistemas de engagement // Nuevas relaciones para sistemas de engagement
PlayerAchievement PlayerAchievement[] PlayerAchievement PlayerAchievement[]
QuestProgress QuestProgress[] QuestProgress QuestProgress[]
PlayerStats PlayerStats[] PlayerStats PlayerStats[]
PlayerStreak PlayerStreak[] PlayerStreak PlayerStreak[]
AuditLog AuditLog[] AuditLog AuditLog[]
PlayerStatusEffect PlayerStatusEffect[]
} }
/** /**
@@ -769,27 +769,27 @@ model ScheduledMobAttack {
* ----------------------------------------------------------------------------- * -----------------------------------------------------------------------------
*/ */
model Achievement { model Achievement {
id String @id @default(cuid()) id String @id @default(cuid())
key String key String
name String name String
description String description String
icon String? icon String?
category String // "mining", "crafting", "combat", "economy", "exploration" category String // "mining", "crafting", "combat", "economy", "exploration"
// Requisitos para desbloquear (JSON flexible) // Requisitos para desbloquear (JSON flexible)
requirements Json // { type: "mine_count", value: 100 } requirements Json // { type: "mine_count", value: 100 }
// Recompensas al desbloquear // Recompensas al desbloquear
rewards Json? // { coins: 500, items: [...], title: "..." } rewards Json? // { coins: 500, items: [...], title: "..." }
guildId String? guildId String?
guild Guild? @relation(fields: [guildId], references: [id]) guild Guild? @relation(fields: [guildId], references: [id])
// Logros desbloqueados por usuarios // Logros desbloqueados por usuarios
unlocked PlayerAchievement[] unlocked PlayerAchievement[]
hidden Boolean @default(false) // logros secretos hidden Boolean @default(false) // logros secretos
points Int @default(10) // puntos que otorga el logro points Int @default(10) // puntos que otorga el logro
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -799,7 +799,7 @@ model Achievement {
} }
model PlayerAchievement { model PlayerAchievement {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
guildId String guildId String
achievementId String achievementId String
@@ -808,10 +808,10 @@ model PlayerAchievement {
guild Guild @relation(fields: [guildId], references: [id]) guild Guild @relation(fields: [guildId], references: [id])
achievement Achievement @relation(fields: [achievementId], references: [id]) achievement Achievement @relation(fields: [achievementId], references: [id])
progress Int @default(0) // progreso actual hacia el logro progress Int @default(0) // progreso actual hacia el logro
unlockedAt DateTime? // null si aún no está desbloqueado unlockedAt DateTime? // null si aún no está desbloqueado
notified Boolean @default(false) // si ya se notificó al usuario notified Boolean @default(false) // si ya se notificó al usuario
metadata Json? metadata Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -826,33 +826,33 @@ model PlayerAchievement {
* ----------------------------------------------------------------------------- * -----------------------------------------------------------------------------
*/ */
model Quest { model Quest {
id String @id @default(cuid()) id String @id @default(cuid())
key String key String
name String name String
description String description String
icon String? icon String?
// Tipo de misión // Tipo de misión
type String // "daily", "weekly", "event", "permanent" type String // "daily", "weekly", "event", "permanent"
category String // "mining", "combat", "economy", "exploration" category String // "mining", "combat", "economy", "exploration"
// Requisitos // Requisitos
requirements Json // { type: "mine", count: 10 } requirements Json // { type: "mine", count: 10 }
// Recompensas // Recompensas
rewards Json // { coins: 500, items: [...], xp: 100 } rewards Json // { coins: 500, items: [...], xp: 100 }
// Disponibilidad // Disponibilidad
startAt DateTime? startAt DateTime?
endAt DateTime? endAt DateTime?
guildId String? guildId String?
guild Guild? @relation(fields: [guildId], references: [id]) guild Guild? @relation(fields: [guildId], references: [id])
progress QuestProgress[] progress QuestProgress[]
active Boolean @default(true) active Boolean @default(true)
repeatable Boolean @default(false) // si se puede repetir después de completar repeatable Boolean @default(false) // si se puede repetir después de completar
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -863,18 +863,18 @@ model Quest {
} }
model QuestProgress { model QuestProgress {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
guildId String guildId String
questId String questId String
progress Int @default(0) // progreso actual progress Int @default(0) // progreso actual
completed Boolean @default(false) completed Boolean @default(false)
claimed Boolean @default(false) // si ya reclamó recompensa claimed Boolean @default(false) // si ya reclamó recompensa
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
guild Guild @relation(fields: [guildId], references: [id]) guild Guild @relation(fields: [guildId], references: [id])
quest Quest @relation(fields: [questId], references: [id]) quest Quest @relation(fields: [questId], references: [id])
completedAt DateTime? completedAt DateTime?
claimedAt DateTime? claimedAt DateTime?
@@ -900,34 +900,34 @@ model PlayerStats {
guildId String guildId String
// Stats de minijuegos // Stats de minijuegos
minesCompleted Int @default(0) minesCompleted Int @default(0)
fishingCompleted Int @default(0) fishingCompleted Int @default(0)
fightsCompleted Int @default(0) fightsCompleted Int @default(0)
farmsCompleted Int @default(0) farmsCompleted Int @default(0)
// Stats de combate // Stats de combate
mobsDefeated Int @default(0) mobsDefeated Int @default(0)
damageDealt Int @default(0) damageDealt Int @default(0)
damageTaken Int @default(0) damageTaken Int @default(0)
timesDefeated Int @default(0) timesDefeated Int @default(0)
// Stats de economía // Stats de economía
totalCoinsEarned Int @default(0) totalCoinsEarned Int @default(0)
totalCoinsSpent Int @default(0) totalCoinsSpent Int @default(0)
itemsCrafted Int @default(0) itemsCrafted Int @default(0)
itemsSmelted Int @default(0) itemsSmelted Int @default(0)
itemsPurchased Int @default(0) itemsPurchased Int @default(0)
// Stats de items // Stats de items
chestsOpened Int @default(0) chestsOpened Int @default(0)
itemsConsumed Int @default(0) itemsConsumed Int @default(0)
itemsEquipped Int @default(0) itemsEquipped Int @default(0)
// Récords personales // Récords personales
highestDamageDealt Int @default(0) highestDamageDealt Int @default(0)
longestWinStreak Int @default(0) longestWinStreak Int @default(0)
currentWinStreak Int @default(0) currentWinStreak Int @default(0)
mostCoinsAtOnce Int @default(0) mostCoinsAtOnce Int @default(0)
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
guild Guild @relation(fields: [guildId], references: [id]) guild Guild @relation(fields: [guildId], references: [id])
@@ -939,23 +939,54 @@ model PlayerStats {
@@index([userId, guildId]) @@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) * Sistema de Rachas (Streaks)
* ----------------------------------------------------------------------------- * -----------------------------------------------------------------------------
*/ */
model PlayerStreak { model PlayerStreak {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
guildId String guildId String
currentStreak Int @default(0) currentStreak Int @default(0)
longestStreak Int @default(0) longestStreak Int @default(0)
lastActiveDate DateTime @default(now()) lastActiveDate DateTime @default(now())
totalDaysActive Int @default(0) totalDaysActive Int @default(0)
// Recompensas reclamadas por día // 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]) user User @relation(fields: [userId], references: [id])
guild Guild @relation(fields: [guildId], references: [id]) guild Guild @relation(fields: [guildId], references: [id])
@@ -973,15 +1004,15 @@ model PlayerStreak {
* ----------------------------------------------------------------------------- * -----------------------------------------------------------------------------
*/ */
model AuditLog { model AuditLog {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
guildId String guildId String
action String // "buy", "craft", "trade", "equip", "mine", "fight", etc. action String // "buy", "craft", "trade", "equip", "mine", "fight", etc.
target String? // ID del item/mob/área afectado target String? // ID del item/mob/área afectado
details Json? // detalles adicionales details Json? // detalles adicionales
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
guild Guild @relation(fields: [guildId], references: [id]) guild Guild @relation(fields: [guildId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@@ -1,4 +1,8 @@
import { prisma } from "../../core/database/prisma"; import { prisma } from "../../core/database/prisma";
import {
getActiveStatusEffects,
computeDerivedModifiers,
} from "./statusEffectsService";
import type { ItemProps } from "../economy/types"; import type { ItemProps } from "../economy/types";
import { ensureUserAndGuildExist } from "../core/userService"; import { ensureUserAndGuildExist } from "../core/userService";
@@ -130,6 +134,24 @@ export async function getEffectiveStats(
} catch { } catch {
// silencioso: si falla stats no bloquea // 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 }; return { damage, defense, maxHp, hp };
} }

View File

@@ -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<string, any>;
}
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" },
});
}

View File

@@ -82,12 +82,18 @@ export function combatSummaryRPG(c: {
playerEndHp?: number | null; playerEndHp?: number | null;
outcome?: "victory" | "defeat"; outcome?: "victory" | "defeat";
maxRefHp?: number; // para cálculo visual si difiere maxRefHp?: number; // para cálculo visual si difiere
autoDefeatNoWeapon?: boolean;
}) { }) {
const header = `**Combate (${outcomeLabel(c.outcome)})**`; const header = `**Combate (${outcomeLabel(c.outcome)})**`;
const lines = [ const lines = [
`• Mobs: ${c.mobs} | Derrotados: ${c.mobsDefeated}/${c.mobs}`, `• Mobs: ${c.mobs} | Derrotados: ${c.mobsDefeated}/${c.mobs}`,
`• Daño hecho: ${c.totalDamageDealt} | Daño recibido: ${c.totalDamageTaken}`, `• 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) { if (c.playerStartHp != null && c.playerEndHp != null) {
const maxHp = c.maxRefHp || Math.max(c.playerStartHp, c.playerEndHp); const maxHp = c.maxRefHp || Math.max(c.playerStartHp, c.playerEndHp);
lines.push( lines.push(

View File

@@ -387,130 +387,167 @@ export async function runMinigame(
const eff = await getEffectiveStats(userId, guildId); const eff = await getEffectiveStats(userId, guildId);
const playerState = await ensurePlayerState(userId, guildId); const playerState = await ensurePlayerState(userId, guildId);
const startHp = eff.hp; // HP actual persistente const startHp = eff.hp; // HP actual persistente
let currentHp = startHp; // Regla: si el jugador no tiene arma (damage <=0) no puede infligir daño real y perderá automáticamente contra cualquier mob.
const mobLogs: CombatSummary["mobs"] = []; // En lugar de simular rondas irreales con daño mínimo artificial, forzamos derrota directa manteniendo coherencia.
let totalDealt = 0; if (!eff.damage || eff.damage <= 0) {
let totalTaken = 0; // Registrar derrota simple contra la lista de mobs (no se derrotan mobs).
let totalMobsDefeated = 0; const mobLogs: CombatSummary["mobs"] = mobsSpawned.map((mk) => ({
// Variación de ±20% mobKey: mk,
const variance = (base: number) => { maxHp: 0,
const factor = 0.8 + Math.random() * 0.4; // 0.8 - 1.2 defeated: false,
return base * factor; totalDamageDealt: 0,
}; totalDamageTakenFromMob: 0,
for (const mobKey of mobsSpawned) { rounds: [],
if (currentHp <= 0) break; // jugador derrotado antes de iniciar este mob }));
// Stats simples del mob (placeholder mejorable con tabla real) // Aplicar daño simulado: mobs atacan una vez (opcional). Aquí asumimos que el jugador cae a 0 directamente para simplificar.
const mobBaseHp = 10 + Math.floor(Math.random() * 6); // 10-15 const endHp = Math.max(1, Math.floor(eff.maxHp * 0.5));
let mobHp = mobBaseHp; await adjustHP(userId, guildId, endHp - playerState.hp); // regen al 50%
const rounds: any[] = []; await updateStats(userId, guildId, {
let round = 1; damageTaken: 0, // opcional: podría ponerse un valor fijo si quieres penalizar
let mobDamageDealt = 0; // daño que jugador hace a este mob timesDefeated: 1,
let mobDamageTakenFromMob = 0; // daño que jugador recibe de este mob } as any);
while (mobHp > 0 && currentHp > 0 && round <= 12) { // Reset de racha si existía
// 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<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({ await prisma.playerStats.update({
where: { userId_guildId: { userId, guildId } }, where: { userId_guildId: { userId, guildId } },
data: { currentWinStreak: 0 }, data: { currentWinStreak: 0 },
}); });
} else if (victory) { combatSummary = {
// posible actualización de longestWinStreak si superada ya la maneja updateStats parcialmente; reforzar mobs: mobLogs,
await prisma.$executeRawUnsafe( totalDamageDealt: 0,
`UPDATE "PlayerStats" SET "longestWinStreak" = GREATEST("longestWinStreak", "currentWinStreak") WHERE "userId" = $1 AND "guildId" = $2`, totalDamageTaken: 0,
userId, mobsDefeated: 0,
guildId 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<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
);
}
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 // Registrar la ejecución

View File

@@ -92,4 +92,5 @@ export type CombatSummary = {
playerStartHp?: number; playerStartHp?: number;
playerEndHp?: number; playerEndHp?: number;
outcome?: "victory" | "defeat"; outcome?: "victory" | "defeat";
autoDefeatNoWeapon?: boolean; // true si la derrota fue inmediata por no tener arma (damage <= 0)
}; };