feat: integrate combat system with equipment and HP persistence
- Refactored combat mechanics to utilize player equipment stats for damage and defense calculations. - Implemented a system to track player HP across combat encounters, including regeneration rules upon defeat. - Enhanced tool management by introducing a logging system for tool breaks, allowing players to view recent tool usage and breakage events. - Added commands for viewing combat history and tool break logs, providing players with insights into their performance. - Updated various game commands to utilize new formatting functions for combat summaries and tool information. - Introduced a new mob data structure to define mob characteristics and scaling based on area levels.
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
import { prisma } from '../../core/database/prisma';
|
||||
import type { ItemProps } from '../economy/types';
|
||||
import { ensureUserAndGuildExist } from '../core/userService';
|
||||
import { prisma } from "../../core/database/prisma";
|
||||
import type { ItemProps } from "../economy/types";
|
||||
import { ensureUserAndGuildExist } from "../core/userService";
|
||||
|
||||
function parseItemProps(json: unknown): ItemProps {
|
||||
if (!json || typeof json !== 'object') return {};
|
||||
if (!json || typeof json !== "object") return {};
|
||||
return json as ItemProps;
|
||||
}
|
||||
|
||||
export async function ensurePlayerState(userId: string, guildId: string) {
|
||||
// Asegurar que User y Guild existan antes de crear/buscar state
|
||||
await ensureUserAndGuildExist(userId, guildId);
|
||||
|
||||
|
||||
return prisma.playerState.upsert({
|
||||
where: { userId_guildId: { userId, guildId } },
|
||||
update: {},
|
||||
@@ -21,25 +21,39 @@ export async function ensurePlayerState(userId: string, guildId: string) {
|
||||
export async function getEquipment(userId: string, guildId: string) {
|
||||
// Asegurar que User y Guild existan antes de crear/buscar equipment
|
||||
await ensureUserAndGuildExist(userId, guildId);
|
||||
|
||||
|
||||
const eq = await prisma.playerEquipment.upsert({
|
||||
where: { userId_guildId: { userId, guildId } },
|
||||
update: {},
|
||||
create: { userId, guildId },
|
||||
});
|
||||
const weapon = eq.weaponItemId ? await prisma.economyItem.findUnique({ where: { id: eq.weaponItemId } }) : null;
|
||||
const armor = eq.armorItemId ? await prisma.economyItem.findUnique({ where: { id: eq.armorItemId } }) : null;
|
||||
const cape = eq.capeItemId ? await prisma.economyItem.findUnique({ where: { id: eq.capeItemId } }) : null;
|
||||
const weapon = eq.weaponItemId
|
||||
? await prisma.economyItem.findUnique({ where: { id: eq.weaponItemId } })
|
||||
: null;
|
||||
const armor = eq.armorItemId
|
||||
? await prisma.economyItem.findUnique({ where: { id: eq.armorItemId } })
|
||||
: null;
|
||||
const cape = eq.capeItemId
|
||||
? await prisma.economyItem.findUnique({ where: { id: eq.capeItemId } })
|
||||
: null;
|
||||
return { eq, weapon, armor, cape } as const;
|
||||
}
|
||||
|
||||
export async function setEquipmentSlot(userId: string, guildId: string, slot: 'weapon'|'armor'|'cape', itemId: string | null) {
|
||||
export async function setEquipmentSlot(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
slot: "weapon" | "armor" | "cape",
|
||||
itemId: string | null
|
||||
) {
|
||||
// Asegurar que User y Guild existan antes de crear/actualizar equipment
|
||||
await ensureUserAndGuildExist(userId, guildId);
|
||||
|
||||
const data = slot === 'weapon' ? { weaponItemId: itemId }
|
||||
: slot === 'armor' ? { armorItemId: itemId }
|
||||
: { capeItemId: itemId };
|
||||
|
||||
const data =
|
||||
slot === "weapon"
|
||||
? { weaponItemId: itemId }
|
||||
: slot === "armor"
|
||||
? { armorItemId: itemId }
|
||||
: { capeItemId: itemId };
|
||||
return prisma.playerEquipment.upsert({
|
||||
where: { userId_guildId: { userId, guildId } },
|
||||
update: data,
|
||||
@@ -54,22 +68,36 @@ export type EffectiveStats = {
|
||||
hp: number;
|
||||
};
|
||||
|
||||
async function getMutationBonuses(userId: string, guildId: string, itemId?: string | null) {
|
||||
async function getMutationBonuses(
|
||||
userId: string,
|
||||
guildId: string,
|
||||
itemId?: string | null
|
||||
) {
|
||||
if (!itemId) return { damageBonus: 0, defenseBonus: 0, maxHpBonus: 0 };
|
||||
const inv = await prisma.inventoryEntry.findUnique({ where: { userId_guildId_itemId: { userId, guildId, itemId } } });
|
||||
const inv = await prisma.inventoryEntry.findUnique({
|
||||
where: { userId_guildId_itemId: { userId, guildId, itemId } },
|
||||
});
|
||||
if (!inv) return { damageBonus: 0, defenseBonus: 0, maxHpBonus: 0 };
|
||||
const links = await prisma.inventoryItemMutation.findMany({ where: { inventoryId: inv.id }, include: { mutation: true } });
|
||||
let damageBonus = 0, defenseBonus = 0, maxHpBonus = 0;
|
||||
const links = await prisma.inventoryItemMutation.findMany({
|
||||
where: { inventoryId: inv.id },
|
||||
include: { mutation: true },
|
||||
});
|
||||
let damageBonus = 0,
|
||||
defenseBonus = 0,
|
||||
maxHpBonus = 0;
|
||||
for (const l of links) {
|
||||
const eff = (l.mutation.effects as any) || {};
|
||||
if (typeof eff.damageBonus === 'number') damageBonus += eff.damageBonus;
|
||||
if (typeof eff.defenseBonus === 'number') defenseBonus += eff.defenseBonus;
|
||||
if (typeof eff.maxHpBonus === 'number') maxHpBonus += eff.maxHpBonus;
|
||||
if (typeof eff.damageBonus === "number") damageBonus += eff.damageBonus;
|
||||
if (typeof eff.defenseBonus === "number") defenseBonus += eff.defenseBonus;
|
||||
if (typeof eff.maxHpBonus === "number") maxHpBonus += eff.maxHpBonus;
|
||||
}
|
||||
return { damageBonus, defenseBonus, maxHpBonus };
|
||||
}
|
||||
|
||||
export async function getEffectiveStats(userId: string, guildId: string): Promise<EffectiveStats> {
|
||||
export async function getEffectiveStats(
|
||||
userId: string,
|
||||
guildId: string
|
||||
): Promise<EffectiveStats> {
|
||||
const state = await ensurePlayerState(userId, guildId);
|
||||
const { weapon, armor, cape } = await getEquipment(userId, guildId);
|
||||
const w = parseItemProps(weapon?.props);
|
||||
@@ -80,10 +108,28 @@ export async function getEffectiveStats(userId: string, guildId: string): Promis
|
||||
const mutA = await getMutationBonuses(userId, guildId, armor?.id ?? null);
|
||||
const mutC = await getMutationBonuses(userId, guildId, cape?.id ?? null);
|
||||
|
||||
const damage = Math.max(0, (w.damage ?? 0) + mutW.damageBonus);
|
||||
let damage = Math.max(0, (w.damage ?? 0) + mutW.damageBonus);
|
||||
const defense = Math.max(0, (a.defense ?? 0) + mutA.defenseBonus);
|
||||
const maxHp = Math.max(1, state.maxHp + (c.maxHpBonus ?? 0) + mutC.maxHpBonus);
|
||||
const maxHp = Math.max(
|
||||
1,
|
||||
state.maxHp + (c.maxHpBonus ?? 0) + mutC.maxHpBonus
|
||||
);
|
||||
const hp = Math.min(state.hp, maxHp);
|
||||
// Buff por racha de victorias: 1% daño extra cada 3 victorias consecutivas (cap 30%)
|
||||
try {
|
||||
const stats = await prisma.playerStats.findUnique({
|
||||
where: { userId_guildId: { userId, guildId } },
|
||||
});
|
||||
if (stats) {
|
||||
const streak = stats.currentWinStreak || 0;
|
||||
const steps = Math.floor(streak / 3);
|
||||
const bonusPct = Math.min(steps * 0.01, 0.3); // cap 30%
|
||||
if (bonusPct > 0)
|
||||
damage = Math.max(0, Math.round(damage * (1 + bonusPct)));
|
||||
}
|
||||
} catch {
|
||||
// silencioso: si falla stats no bloquea
|
||||
}
|
||||
return { damage, defense, maxHp, hp };
|
||||
}
|
||||
|
||||
@@ -93,5 +139,8 @@ export async function adjustHP(userId: string, guildId: string, delta: number) {
|
||||
const c = parseItemProps(cape?.props);
|
||||
const maxHp = Math.max(1, state.maxHp + (c.maxHpBonus ?? 0));
|
||||
const next = Math.min(maxHp, Math.max(0, state.hp + delta));
|
||||
return prisma.playerState.update({ where: { userId_guildId: { userId, guildId } }, data: { hp: next, maxHp } });
|
||||
return prisma.playerState.update({
|
||||
where: { userId_guildId: { userId, guildId } },
|
||||
data: { hp: next, maxHp },
|
||||
});
|
||||
}
|
||||
|
||||
101
src/game/lib/rpgFormat.ts
Normal file
101
src/game/lib/rpgFormat.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Utilidades de formato visual estilo RPG para comandos y resúmenes.
|
||||
// Centraliza lógica repetida (barras de corazones, durabilidad, etiquetas de herramientas).
|
||||
|
||||
export function heartsBar(
|
||||
current: number,
|
||||
max: number,
|
||||
opts?: { segments?: number; fullChar?: string; emptyChar?: string }
|
||||
) {
|
||||
const segments = opts?.segments ?? Math.min(20, max); // límite visual
|
||||
const full = opts?.fullChar ?? "❤";
|
||||
const empty = opts?.emptyChar ?? "♡";
|
||||
const clampedMax = Math.max(1, max);
|
||||
const ratio = current / clampedMax;
|
||||
const filled = Math.max(0, Math.min(segments, Math.round(ratio * segments)));
|
||||
return full.repeat(filled) + empty.repeat(segments - filled);
|
||||
}
|
||||
|
||||
export function durabilityBar(remaining: number, max: number, segments = 10) {
|
||||
const safeMax = Math.max(1, max);
|
||||
const ratio = Math.max(0, Math.min(1, remaining / safeMax));
|
||||
const filled = Math.round(ratio * segments);
|
||||
const bar = Array.from({ length: segments })
|
||||
.map((_, i) => (i < filled ? "█" : "░"))
|
||||
.join("");
|
||||
return `[${bar}] ${Math.max(0, remaining)}/${safeMax}`;
|
||||
}
|
||||
|
||||
export function formatToolLabel(params: {
|
||||
key: string;
|
||||
displayName: string;
|
||||
instancesRemaining?: number | null;
|
||||
broken?: boolean;
|
||||
brokenInstance?: boolean;
|
||||
durabilityDelta?: number | null;
|
||||
remaining?: number | null;
|
||||
max?: number | null;
|
||||
source?: string | null;
|
||||
fallbackIcon?: string;
|
||||
}) {
|
||||
const {
|
||||
key,
|
||||
displayName,
|
||||
instancesRemaining,
|
||||
broken,
|
||||
brokenInstance,
|
||||
durabilityDelta,
|
||||
remaining,
|
||||
max,
|
||||
source,
|
||||
fallbackIcon = "🔧",
|
||||
} = params;
|
||||
|
||||
const multi =
|
||||
instancesRemaining && instancesRemaining > 1
|
||||
? ` (x${instancesRemaining})`
|
||||
: "";
|
||||
const base = `${displayName || key}${multi}`;
|
||||
let status = "";
|
||||
if (broken) status = " (agotada)";
|
||||
else if (brokenInstance)
|
||||
status = ` (instancia rota, quedan ${instancesRemaining})`;
|
||||
const delta = durabilityDelta != null ? ` (-${durabilityDelta} dur.)` : "";
|
||||
const dur =
|
||||
remaining != null && max != null
|
||||
? `\nDurabilidad: ${durabilityBar(remaining, max)}`
|
||||
: "";
|
||||
const src = source ? ` \`(${source})\`` : "";
|
||||
return `${base}${status}${delta}${src}${dur}`;
|
||||
}
|
||||
|
||||
export function outcomeLabel(outcome?: "victory" | "defeat") {
|
||||
if (!outcome) return "";
|
||||
return outcome === "victory" ? "🏆 Victoria" : "💀 Derrota";
|
||||
}
|
||||
|
||||
export function combatSummaryRPG(c: {
|
||||
mobs: number;
|
||||
mobsDefeated: number;
|
||||
totalDamageDealt: number;
|
||||
totalDamageTaken: number;
|
||||
playerStartHp?: number | null;
|
||||
playerEndHp?: number | null;
|
||||
outcome?: "victory" | "defeat";
|
||||
maxRefHp?: number; // para cálculo visual si difiere
|
||||
}) {
|
||||
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.playerStartHp != null && c.playerEndHp != null) {
|
||||
const maxHp = c.maxRefHp || Math.max(c.playerStartHp, c.playerEndHp);
|
||||
lines.push(
|
||||
`• HP: ${c.playerStartHp} → ${c.playerEndHp} ${heartsBar(
|
||||
c.playerEndHp,
|
||||
maxHp
|
||||
)}`
|
||||
);
|
||||
}
|
||||
return `${header}\n${lines.join("\n")}`;
|
||||
}
|
||||
33
src/game/lib/toolBreakLog.ts
Normal file
33
src/game/lib/toolBreakLog.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Logger en memoria de rupturas de herramientas.
|
||||
// Reemplazable en el futuro por una tabla ToolBreakLog.
|
||||
|
||||
export interface ToolBreakEvent {
|
||||
ts: number;
|
||||
userId: string;
|
||||
guildId: string;
|
||||
toolKey: string;
|
||||
brokenInstance: boolean; // true si fue una instancia, false si se agotó totalmente (última)
|
||||
instancesRemaining: number;
|
||||
}
|
||||
|
||||
const MAX_EVENTS = 200;
|
||||
const buffer: ToolBreakEvent[] = [];
|
||||
|
||||
export function logToolBreak(ev: ToolBreakEvent) {
|
||||
buffer.unshift(ev);
|
||||
if (buffer.length > MAX_EVENTS) buffer.pop();
|
||||
}
|
||||
|
||||
export function getToolBreaks(
|
||||
limit = 20,
|
||||
guildFilter?: string,
|
||||
userFilter?: string
|
||||
) {
|
||||
return buffer
|
||||
.filter(
|
||||
(e) =>
|
||||
(!guildFilter || e.guildId === guildFilter) &&
|
||||
(!userFilter || e.userId === userFilter)
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
@@ -5,6 +5,13 @@ import {
|
||||
findItemByKey,
|
||||
getInventoryEntry,
|
||||
} from "../economy/service";
|
||||
import {
|
||||
getEffectiveStats,
|
||||
adjustHP,
|
||||
ensurePlayerState,
|
||||
} from "../combat/equipmentService"; // 🟩 local authoritative
|
||||
import { logToolBreak } from "../lib/toolBreakLog";
|
||||
import { updateStats } from "../stats/service"; // 🟩 local authoritative
|
||||
import type { ItemProps, InventoryState } from "../economy/types";
|
||||
import type {
|
||||
LevelRequirements,
|
||||
@@ -95,25 +102,60 @@ async function validateRequirements(
|
||||
req?: LevelRequirements,
|
||||
toolKey?: string
|
||||
) {
|
||||
if (!req) return { toolKeyUsed: undefined as string | undefined };
|
||||
if (!req)
|
||||
return {
|
||||
toolKeyUsed: undefined as string | undefined,
|
||||
toolSource: undefined as "provided" | "equipped" | "auto" | undefined,
|
||||
};
|
||||
const toolReq = req.tool;
|
||||
if (!toolReq) return { toolKeyUsed: undefined as string | undefined };
|
||||
if (!toolReq)
|
||||
return {
|
||||
toolKeyUsed: undefined as string | undefined,
|
||||
toolSource: undefined,
|
||||
};
|
||||
|
||||
let toolKeyUsed = toolKey;
|
||||
let toolSource: "provided" | "equipped" | "auto" | undefined = undefined;
|
||||
if (toolKeyUsed) toolSource = "provided";
|
||||
|
||||
// Auto-select tool when required and not provided
|
||||
if (!toolKeyUsed && toolReq.required && toolReq.toolType) {
|
||||
toolKeyUsed =
|
||||
(await findBestToolKey(userId, guildId, toolReq.toolType, {
|
||||
// 1. Intentar herramienta equipada en slot weapon si coincide el tipo
|
||||
const equip = await prisma.playerEquipment.findUnique({
|
||||
where: { userId_guildId: { userId, guildId } },
|
||||
include: { weaponItem: true } as any,
|
||||
});
|
||||
if (equip?.weaponItemId && equip?.weaponItem) {
|
||||
const wProps = parseItemProps((equip as any).weaponItem.props);
|
||||
if (wProps.tool?.type === toolReq.toolType) {
|
||||
const tier = Math.max(0, wProps.tool?.tier ?? 0);
|
||||
if (
|
||||
(toolReq.minTier == null || tier >= toolReq.minTier) &&
|
||||
(!toolReq.allowedKeys ||
|
||||
toolReq.allowedKeys.includes((equip as any).weaponItem.key))
|
||||
) {
|
||||
toolKeyUsed = (equip as any).weaponItem.key;
|
||||
toolSource = "equipped";
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Best inventory si no se obtuvo del equipo
|
||||
if (!toolKeyUsed) {
|
||||
const best = await findBestToolKey(userId, guildId, toolReq.toolType, {
|
||||
minTier: toolReq.minTier,
|
||||
allowedKeys: toolReq.allowedKeys,
|
||||
})) ?? undefined;
|
||||
});
|
||||
if (best) {
|
||||
toolKeyUsed = best;
|
||||
toolSource = "auto";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// herramienta requerida
|
||||
if (toolReq.required && !toolKeyUsed)
|
||||
throw new Error("Se requiere una herramienta adecuada");
|
||||
if (!toolKeyUsed) return { toolKeyUsed: undefined };
|
||||
if (!toolKeyUsed) return { toolKeyUsed: undefined, toolSource };
|
||||
|
||||
// verificar herramienta
|
||||
const toolItem = await findItemByKey(guildId, toolKeyUsed);
|
||||
@@ -131,7 +173,7 @@ async function validateRequirements(
|
||||
if (toolReq.allowedKeys && !toolReq.allowedKeys.includes(toolKeyUsed))
|
||||
throw new Error("Esta herramienta no es válida para esta área");
|
||||
|
||||
return { toolKeyUsed };
|
||||
return { toolKeyUsed, toolSource };
|
||||
}
|
||||
|
||||
async function applyRewards(
|
||||
@@ -261,10 +303,14 @@ async function reduceToolDurability(
|
||||
});
|
||||
// Placeholder: logging de ruptura (migrar a ToolBreakLog futuro)
|
||||
if (brokenInstance) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[tool-break] user=${userId} guild=${guildId} toolKey=${toolKey}`
|
||||
);
|
||||
logToolBreak({
|
||||
ts: Date.now(),
|
||||
userId,
|
||||
guildId,
|
||||
toolKey,
|
||||
brokenInstance: !broken, // true = solo una instancia
|
||||
instancesRemaining,
|
||||
});
|
||||
}
|
||||
return {
|
||||
broken,
|
||||
@@ -329,76 +375,141 @@ export async function runMinigame(
|
||||
max: t.max,
|
||||
brokenInstance: t.brokenInstance,
|
||||
instancesRemaining: t.instancesRemaining,
|
||||
toolSource: reqRes.toolSource ?? (opts?.toolKey ? "provided" : "auto"),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Combate Básico (placeholder) ---
|
||||
// Objetivo: procesar mobs spawneados y generar resumen estadístico simple.
|
||||
// Futuro: stats jugador, equipo, habilidades. Ahora: valores fijos pseudo-aleatorios.
|
||||
// (Eliminado combate placeholder; sustituido por sistema integrado más abajo)
|
||||
// --- Combate Integrado con Equipo y HP Persistente ---
|
||||
let combatSummary: CombatSummary | undefined;
|
||||
if (mobsSpawned.length > 0) {
|
||||
// Obtener stats efectivos del jugador (arma = daño, armadura = defensa, capa = maxHp extra + mutaciones)
|
||||
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 defeated = 0;
|
||||
const basePlayerAtk = 5 + Math.random() * 3; // placeholder
|
||||
const basePlayerDef = 1 + Math.random() * 2; // placeholder
|
||||
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) {
|
||||
// Se podría leer tabla real de mobs (stats json en prisma.mob) en futuro.
|
||||
// Por ahora HP aleatorio controlado.
|
||||
const maxHp = 8 + Math.floor(Math.random() * 8); // 8-15
|
||||
let hp = maxHp;
|
||||
const rounds = [] as any[];
|
||||
let roundIndex = 1;
|
||||
let mobTotalDealt = 0;
|
||||
let mobTotalTaken = 0;
|
||||
while (hp > 0 && roundIndex <= 10) {
|
||||
// limite de 10 rondas por mob
|
||||
const playerDamage = Math.max(
|
||||
1,
|
||||
Math.round(basePlayerAtk + Math.random() * 2)
|
||||
);
|
||||
hp -= playerDamage;
|
||||
mobTotalDealt += playerDamage;
|
||||
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 (hp > 0) {
|
||||
// Mob sólo pega si sigue vivo
|
||||
const mobAtk = 2 + Math.random() * 3; // 2-5
|
||||
const mitigated = Math.max(0, mobAtk - basePlayerDef * 0.5);
|
||||
playerTaken = Math.round(mitigated);
|
||||
totalTaken += playerTaken;
|
||||
mobTotalTaken += playerTaken;
|
||||
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: roundIndex,
|
||||
round,
|
||||
playerDamageDealt: playerDamage,
|
||||
playerDamageTaken: playerTaken,
|
||||
mobRemainingHp: Math.max(0, hp),
|
||||
mobDefeated: hp <= 0,
|
||||
mobRemainingHp: Math.max(0, mobHp),
|
||||
mobDefeated: mobHp <= 0,
|
||||
});
|
||||
if (hp <= 0) {
|
||||
defeated++;
|
||||
if (mobHp <= 0) {
|
||||
totalMobsDefeated++;
|
||||
break;
|
||||
}
|
||||
roundIndex++;
|
||||
if (currentHp <= 0) break;
|
||||
round++;
|
||||
}
|
||||
mobLogs.push({
|
||||
mobKey,
|
||||
maxHp,
|
||||
defeated: hp <= 0,
|
||||
totalDamageDealt: mobTotalDealt,
|
||||
totalDamageTakenFromMob: mobTotalTaken,
|
||||
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: defeated,
|
||||
victory: defeated === mobsSpawned.length,
|
||||
mobsDefeated: totalMobsDefeated,
|
||||
victory,
|
||||
playerStartHp: startHp,
|
||||
playerEndHp: endHp,
|
||||
outcome: victory ? "victory" : "defeat",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ export type RunResult = {
|
||||
max?: number; // durabilidad máxima configurada
|
||||
brokenInstance?: boolean; // true si solo se rompió una instancia
|
||||
instancesRemaining?: number; // instancias que quedan después del uso
|
||||
toolSource?: "provided" | "equipped" | "auto"; // origen de la selección
|
||||
};
|
||||
combat?: CombatSummary; // resumen de combate si hubo mobs y se procesó
|
||||
};
|
||||
@@ -88,4 +89,7 @@ export type CombatSummary = {
|
||||
totalDamageTaken: number;
|
||||
mobsDefeated: number;
|
||||
victory: boolean; // true si el jugador sobrevivió a todos los mobs
|
||||
playerStartHp?: number;
|
||||
playerEndHp?: number;
|
||||
outcome?: "victory" | "defeat";
|
||||
};
|
||||
|
||||
72
src/game/mobs/mobData.ts
Normal file
72
src/game/mobs/mobData.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Definición declarativa de mobs (scaffolding)
|
||||
// Futuro: migrar a tabla prisma.mob enriquecida o cache Appwrite.
|
||||
|
||||
export interface BaseMobDefinition {
|
||||
key: string; // identificador único
|
||||
name: string; // nombre visible
|
||||
tier: number; // escala de dificultad base
|
||||
base: {
|
||||
hp: number;
|
||||
attack: number;
|
||||
defense?: number;
|
||||
};
|
||||
scaling?: {
|
||||
hpPerLevel?: number; // incremento por nivel de área
|
||||
attackPerLevel?: number;
|
||||
defensePerLevel?: number;
|
||||
hpMultiplierPerTier?: number; // multiplicador adicional por tier
|
||||
};
|
||||
tags?: string[]; // p.ej. ['undead','beast']
|
||||
rewardMods?: {
|
||||
coinMultiplier?: number;
|
||||
extraDropChance?: number; // 0-1
|
||||
};
|
||||
behavior?: {
|
||||
maxRounds?: number; // override límite de rondas
|
||||
aggressive?: boolean; // si ataca siempre
|
||||
critChance?: number; // 0-1
|
||||
critMultiplier?: number; // default 1.5
|
||||
};
|
||||
}
|
||||
|
||||
// Ejemplos iniciales - se pueden ir expandiendo
|
||||
export const MOB_DEFINITIONS: BaseMobDefinition[] = [
|
||||
{
|
||||
key: "slime.green",
|
||||
name: "Slime Verde",
|
||||
tier: 1,
|
||||
base: { hp: 18, attack: 4 },
|
||||
scaling: { hpPerLevel: 3, attackPerLevel: 0.5 },
|
||||
tags: ["slime"],
|
||||
rewardMods: { coinMultiplier: 0.9 },
|
||||
behavior: { maxRounds: 12, aggressive: true },
|
||||
},
|
||||
{
|
||||
key: "skeleton.basic",
|
||||
name: "Esqueleto",
|
||||
tier: 2,
|
||||
base: { hp: 30, attack: 6, defense: 1 },
|
||||
scaling: { hpPerLevel: 4, attackPerLevel: 0.8, defensePerLevel: 0.2 },
|
||||
tags: ["undead"],
|
||||
rewardMods: { coinMultiplier: 1.1, extraDropChance: 0.05 },
|
||||
behavior: { aggressive: true, critChance: 0.05, critMultiplier: 1.5 },
|
||||
},
|
||||
];
|
||||
|
||||
export function findMobDef(key: string) {
|
||||
return MOB_DEFINITIONS.find((m) => m.key === key) || null;
|
||||
}
|
||||
|
||||
export function computeMobStats(def: BaseMobDefinition, areaLevel: number) {
|
||||
const lvl = Math.max(1, areaLevel);
|
||||
const s = def.scaling || {};
|
||||
const hp = Math.round(def.base.hp + (s.hpPerLevel ?? 0) * (lvl - 1));
|
||||
const atk = +(def.base.attack + (s.attackPerLevel ?? 0) * (lvl - 1)).toFixed(
|
||||
2
|
||||
);
|
||||
const defVal = +(
|
||||
(def.base.defense ?? 0) +
|
||||
(s.defensePerLevel ?? 0) * (lvl - 1)
|
||||
).toFixed(2);
|
||||
return { hp, attack: atk, defense: defVal };
|
||||
}
|
||||
Reference in New Issue
Block a user