feat: implementar sistema de efectos de estado con persistencia en la base de datos
This commit is contained in:
@@ -14,8 +14,6 @@ datasource db {
|
||||
shadowDatabaseUrl = env("XATA_SHADOW_DB")
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* -----------------------------------------------------------------------------
|
||||
* Modelo para el Servidor (Guild)
|
||||
@@ -64,6 +62,7 @@ model Guild {
|
||||
PlayerStats PlayerStats[]
|
||||
PlayerStreak PlayerStreak[]
|
||||
AuditLog AuditLog[]
|
||||
PlayerStatusEffect PlayerStatusEffect[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +98,7 @@ model User {
|
||||
PlayerStats PlayerStats[]
|
||||
PlayerStreak PlayerStreak[]
|
||||
AuditLog AuditLog[]
|
||||
PlayerStatusEffect PlayerStatusEffect[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -939,6 +939,37 @@ 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)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
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(
|
||||
|
||||
@@ -387,6 +387,42 @@ export async function runMinigame(
|
||||
const eff = await getEffectiveStats(userId, guildId);
|
||||
const playerState = await ensurePlayerState(userId, guildId);
|
||||
const startHp = eff.hp; // HP actual persistente
|
||||
// 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 },
|
||||
});
|
||||
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;
|
||||
@@ -512,6 +548,7 @@ export async function runMinigame(
|
||||
outcome: victory ? "victory" : "defeat",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Registrar la ejecución
|
||||
const resultJson: Prisma.InputJsonValue = {
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user