feat: implementar sistema de efectos de estado con persistencia en la base de datos
This commit is contained in:
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
77
src/game/combat/statusEffectsService.ts
Normal file
77
src/game/combat/statusEffectsService.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user