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:
2025-10-09 00:44:30 -05:00
parent b66379d790
commit 2befd4a278
12 changed files with 684 additions and 202 deletions

View File

@@ -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
View 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")}`;
}

View 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);
}

View File

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

View File

@@ -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
View 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 };
}