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

@@ -84,8 +84,6 @@ Demostración/ejemplos:
- Qué hace: Responde con “pong!” (prueba rápida de slash). - Qué hace: Responde con “pong!” (prueba rápida de slash).
## Consejos rápidos ## Consejos rápidos
- ¿No recuerdas el prefix? Usa `@mencionar_al_bot ayuda` o prueba `!ayuda`.
- ¿No te funciona un comando?
- Puede requerir permisos de Administrador o ser solo del dueño. - Puede requerir permisos de Administrador o ser solo del dueño.
- Asegúrate de usarlo en un canal de texto compatible. - Asegúrate de usarlo en un canal de texto compatible.
- Escribe `!ayuda <comando>` para ver el uso correcto. - Escribe `!ayuda <comando>` para ver el uso correcto.
@@ -95,6 +93,51 @@ Demostración/ejemplos:
Copia todo este bloque y pégalo en tu canal de información o bienvenida. Copia todo este bloque y pégalo en tu canal de información o bienvenida.
``` ```
## Sistema RPG (Beta)
El bot incluye un sistema RPG ligero con progreso persistente y combate simplificado.
### Conceptos Clave
- **HP Persistente:** Tu vida (HP) se mantiene entre actividades. Si llegas a 0 en combate, resurges automáticamente al 50% de tu máximo (regeneración de seguridad) y se incrementa `Veces Derrotado`.
- **Rachas de Victoria:** Cada 3 victorias consecutivas obtienes +1% de daño (hasta +30%). Al perder, la racha se reinicia.
- **Herramientas con Durabilidad por Instancia:** Las herramientas no apilables crean instancias independientes; cada uso consume durabilidad de una instancia. Al agotarse una instancia, desaparece solo esa (si quedan otras, se siguen usando). Cuando la última instancia se rompe, el ítem queda completamente agotado.
- **Origen de Herramienta:** El sistema selecciona herramienta en este orden: (1) la que pases directamente (argumento), (2) la equipada (slot apropiado), (3) la mejor del inventario. El comando mostrará su procedencia (`toolSource`).
- **Combate Simplificado:** Se simulan rondas contra mobs; se registra daño infligido/recibido, mobs derrotados y resultado (Victoria/Derrota).
- **Resumen Visual:** Corazones (❤/♡) y barra de durabilidad reflejan estado tras cada acción.
### Comandos RPG Principales
- `!mina [nivel] [toolKey]` — Minar (usa pico).
- `!pescar [nivel] [toolKey]` — Pescar (usa caña).
- `!pelear [nivel] [toolKey]` — Combatir (usa espada).
- `!combate-historial [n]` — Últimos combates resumidos.
- `!tool-breaks [n]` — Rupturas recientes de herramientas (memoria).
- (Opcional futuro) `!tool-info <toolKey>` — Ver instancias y durabilidad.
### Estadísticas (PlayerStats)
Se actualizan automáticamente:
- Actividades: `minesCompleted`, `fishingCompleted`, `fightsCompleted`.
- Combate: `mobsDefeated`, `damageDealt`, `damageTaken`, `timesDefeated`, `currentWinStreak`, `longestWinStreak`.
### Estructura de Resultados
Cada minijuego produce un `RunResult` con bloque `tool` (incluye `toolSource`, `brokenInstance`, `instancesRemaining`) y, si aplica, `combat` con:
- `playerStartHp`, `playerEndHp`, `outcome`, `damageDealt`, `damageTaken`, `mobsDefeated`.
### Próximas Mejoras Planeadas
- Tabla persistente de rupturas (`ToolBreakLog`).
- Definiciones avanzadas de mobs (stats dinámicos, efectos críticos, resistencias).
- Efectos de equipo: críticos, sangrado, bloqueo, robo de vida.
- Consumo de pociones en combate y estados alterados.
- Eventos programados (ataques globales de mobs) vía funciones externas (Appwrite / cron).
### Notas Técnicas
- HP y maxHp: `PlayerState` + bonificaciones de equipo.
- Racha: cálculo en `getEffectiveStats()` (1% cada 3 victorias, tope 30%).
- Durabilidad: lógica por instancia en `reduceToolDurability` (remueve instancia agotada).
- Combate: loop interno en servicio de minijuegos actualiza daño y resultado, persistiendo HP final.
- Logs: Rupturas se guardan en memoria (buffer circular) para inspección rápida.
> Esta sección está en evolución: puede cambiar estructura interna para soportar mobs declarativos y balance más profundo.
📌 Amayo Bot — Guía Rápida de Comandos 📌 Amayo Bot — Guía Rápida de Comandos
Prefix: ! (puedes cambiarlo con !configuracion) Prefix: ! (puedes cambiarlo con !configuracion)

View File

@@ -0,0 +1,66 @@
import type { CommandMessage } from "../../../core/types/commands";
import type Amayo from "../../../core/client";
import { prisma } from "../../../core/database/prisma";
import {
buildDisplay,
dividerBlock,
textBlock,
} from "../../../core/lib/componentsV2";
import { combatSummaryRPG } from "../../../game/lib/rpgFormat";
export const command: CommandMessage = {
name: "combate-historial",
type: "message",
aliases: ["fight-log", "combate-log", "battle-log"],
cooldown: 5,
description:
"Muestra tus últimos combates (resumen de daño, mobs y resultado).",
usage: "combate-historial [cantidad=5]",
run: async (message, args, _client: Amayo) => {
const userId = message.author.id;
const guildId = message.guild!.id;
const limit = Math.min(15, Math.max(1, parseInt(args[0] || "5")));
const runs = await prisma.minigameRun.findMany({
where: { userId, guildId },
orderBy: { finishedAt: "desc" },
take: limit * 2, // tomar extra por si algunas no tienen combate
});
if (!runs.length) {
await message.reply("No tienes combates registrados aún.");
return;
}
const blocks = [
textBlock(`# 📜 Historial de Combates (${runs.length})`),
dividerBlock(),
];
let added = 0;
for (const run of runs) {
const result: any = run.result as any;
const combat = result?.combat;
if (!combat) continue;
const areaId = run.areaId;
const area = await prisma.gameArea.findUnique({ where: { id: areaId } });
const areaLabel = area ? area.name || area.key : "Área desconocida";
const line = combatSummaryRPG({
mobs: combat.mobs?.length || result.mobs?.length || 0,
mobsDefeated: combat.mobsDefeated || 0,
totalDamageDealt: combat.totalDamageDealt || 0,
totalDamageTaken: combat.totalDamageTaken || 0,
playerStartHp: combat.playerStartHp,
playerEndHp: combat.playerEndHp,
outcome: combat.outcome,
});
blocks.push(textBlock(`**${areaLabel}** (Lv ${run.level})\n${line}`));
blocks.push(dividerBlock({ divider: false, spacing: 1 }));
added++;
if (added >= limit) break;
}
const display = buildDisplay(0x9156ec, blocks);
await message.reply({ content: "", components: [display] });
},
};

View File

@@ -19,6 +19,7 @@ import {
dividerBlock, dividerBlock,
textBlock, textBlock,
} from "../../../core/lib/componentsV2"; } from "../../../core/lib/componentsV2";
import { formatToolLabel, combatSummaryRPG } from "../../../game/lib/rpgFormat";
import { buildAreaMetadataBlocks } from "./_helpers"; import { buildAreaMetadataBlocks } from "./_helpers";
const MINING_ACCENT = 0xc27c0e; const MINING_ACCENT = 0xc27c0e;
@@ -106,58 +107,38 @@ export const command: CommandMessage = {
? result.mobs.map((m) => `${m}`).join("\n") ? result.mobs.map((m) => `${m}`).join("\n")
: "• —"; : "• —";
const durabilityBar = () => {
if (
!result.tool ||
result.tool.remaining == null ||
result.tool.max == null
)
return "";
const rem = Math.max(0, result.tool.remaining);
const max = Math.max(1, result.tool.max);
const ratio = rem / max;
const totalSegs = 10;
const filled = Math.round(ratio * totalSegs);
const bar = Array.from({ length: totalSegs })
.map((_, i) => (i < filled ? "█" : "░"))
.join("");
return `\nDurabilidad: [${bar}] ${rem}/${max}`;
};
const toolInfo = result.tool?.key const toolInfo = result.tool?.key
? (() => { ? formatToolLabel({
const base = formatItemLabel( key: result.tool.key,
displayName: formatItemLabel(
rewardItems.get(result.tool.key) ?? { rewardItems.get(result.tool.key) ?? {
key: result.tool.key, key: result.tool.key,
name: null, name: null,
icon: null, icon: null,
}, },
{ fallbackIcon: "🔧" } { fallbackIcon: "🔧" }
); ),
if (result.tool.broken) { instancesRemaining: result.tool.instancesRemaining,
return `${base} (agotada)${durabilityBar()}`; broken: result.tool.broken,
} brokenInstance: result.tool.brokenInstance,
if (result.tool.brokenInstance) { durabilityDelta: result.tool.durabilityDelta,
return `${base} (se rompió una instancia, quedan ${ remaining: result.tool.remaining,
result.tool.instancesRemaining max: result.tool.max,
}) (-${result.tool.durabilityDelta ?? 0} dur.)${durabilityBar()}`; source: result.tool.toolSource,
} })
const multi =
result.tool.instancesRemaining &&
result.tool.instancesRemaining > 1
? ` (x${result.tool.instancesRemaining})`
: "";
return `${base}${multi} (-${
result.tool.durabilityDelta ?? 0
} dur.)${durabilityBar()}`;
})()
: "—"; : "—";
const combatSummary = (() => { const combatSummary = result.combat
if (!result.combat) return null; ? combatSummaryRPG({
const c = result.combat; mobs: result.mobs.length,
return `**Combate**\n• Mobs: ${c.mobs.length} | Derrotados: ${c.mobsDefeated}/${result.mobs.length}\n• Daño hecho: ${c.totalDamageDealt} | Daño recibido: ${c.totalDamageTaken}`; mobsDefeated: result.combat.mobsDefeated,
})(); totalDamageDealt: result.combat.totalDamageDealt,
totalDamageTaken: result.combat.totalDamageTaken,
playerStartHp: result.combat.playerStartHp,
playerEndHp: result.combat.playerEndHp,
outcome: result.combat.outcome,
})
: null;
const blocks = [textBlock("# ⛏️ Mina")]; const blocks = [textBlock("# ⛏️ Mina")];

View File

@@ -19,6 +19,7 @@ import {
dividerBlock, dividerBlock,
textBlock, textBlock,
} from "../../../core/lib/componentsV2"; } from "../../../core/lib/componentsV2";
import { formatToolLabel, combatSummaryRPG } from "../../../game/lib/rpgFormat";
import { buildAreaMetadataBlocks } from "./_helpers"; import { buildAreaMetadataBlocks } from "./_helpers";
const FIGHT_ACCENT = 0x992d22; const FIGHT_ACCENT = 0x992d22;
@@ -115,54 +116,37 @@ export const command: CommandMessage = {
const mobsLines = result.mobs.length const mobsLines = result.mobs.length
? result.mobs.map((m) => `${m}`).join("\n") ? result.mobs.map((m) => `${m}`).join("\n")
: "• —"; : "• —";
const durabilityBar = () => {
if (
!result.tool ||
result.tool.remaining == null ||
result.tool.max == null
)
return "";
const rem = Math.max(0, result.tool.remaining);
const max = Math.max(1, result.tool.max);
const ratio = rem / max;
const totalSegs = 10;
const filled = Math.round(ratio * totalSegs);
const bar = Array.from({ length: totalSegs })
.map((_, i) => (i < filled ? "█" : "░"))
.join("");
return `\nDurabilidad: [${bar}] ${rem}/${max}`;
};
const toolInfo = result.tool?.key const toolInfo = result.tool?.key
? (() => { ? formatToolLabel({
const base = formatItemLabel( key: result.tool.key,
displayName: formatItemLabel(
rewardItems.get(result.tool.key) ?? { rewardItems.get(result.tool.key) ?? {
key: result.tool.key, key: result.tool.key,
name: null, name: null,
icon: null, icon: null,
}, },
{ fallbackIcon: "🗡️" } { fallbackIcon: "🗡️" }
); ),
if (result.tool.broken) instancesRemaining: result.tool.instancesRemaining,
return `${base} (agotada)${durabilityBar()}`; broken: result.tool.broken,
if (result.tool.brokenInstance) brokenInstance: result.tool.brokenInstance,
return `${base} (se rompió una instancia, quedan ${ durabilityDelta: result.tool.durabilityDelta,
result.tool.instancesRemaining remaining: result.tool.remaining,
}) (-${result.tool.durabilityDelta ?? 0} dur.)${durabilityBar()}`; max: result.tool.max,
const multi = source: result.tool.toolSource,
result.tool.instancesRemaining && })
result.tool.instancesRemaining > 1
? ` (x${result.tool.instancesRemaining})`
: "";
return `${base}${multi} (-${
result.tool.durabilityDelta ?? 0
} dur.)${durabilityBar()}`;
})()
: "—"; : "—";
const combatSummary = (() => { const combatSummary = result.combat
if (!result.combat) return null; ? combatSummaryRPG({
const c = result.combat; mobs: result.mobs.length,
return `**Combate**\n• Mobs: ${c.mobs.length} | Derrotados: ${c.mobsDefeated}/${result.mobs.length}\n• Daño hecho: ${c.totalDamageDealt} | Daño recibido: ${c.totalDamageTaken}`; mobsDefeated: result.combat.mobsDefeated,
})(); totalDamageDealt: result.combat.totalDamageDealt,
totalDamageTaken: result.combat.totalDamageTaken,
playerStartHp: result.combat.playerStartHp,
playerEndHp: result.combat.playerEndHp,
outcome: result.combat.outcome,
})
: null;
const blocks = [textBlock("# ⚔️ Arena")]; const blocks = [textBlock("# ⚔️ Arena")];

View File

@@ -19,6 +19,7 @@ import {
dividerBlock, dividerBlock,
textBlock, textBlock,
} from "../../../core/lib/componentsV2"; } from "../../../core/lib/componentsV2";
import { formatToolLabel, combatSummaryRPG } from "../../../game/lib/rpgFormat";
import { buildAreaMetadataBlocks } from "./_helpers"; import { buildAreaMetadataBlocks } from "./_helpers";
const FISHING_ACCENT = 0x1abc9c; const FISHING_ACCENT = 0x1abc9c;
@@ -101,54 +102,37 @@ export const command: CommandMessage = {
const mobsLines = result.mobs.length const mobsLines = result.mobs.length
? result.mobs.map((m) => `${m}`).join("\n") ? result.mobs.map((m) => `${m}`).join("\n")
: "• —"; : "• —";
const durabilityBar = () => {
if (
!result.tool ||
result.tool.remaining == null ||
result.tool.max == null
)
return "";
const rem = Math.max(0, result.tool.remaining);
const max = Math.max(1, result.tool.max);
const ratio = rem / max;
const totalSegs = 10;
const filled = Math.round(ratio * totalSegs);
const bar = Array.from({ length: totalSegs })
.map((_, i) => (i < filled ? "█" : "░"))
.join("");
return `\nDurabilidad: [${bar}] ${rem}/${max}`;
};
const toolInfo = result.tool?.key const toolInfo = result.tool?.key
? (() => { ? formatToolLabel({
const base = formatItemLabel( key: result.tool.key,
displayName: formatItemLabel(
rewardItems.get(result.tool.key) ?? { rewardItems.get(result.tool.key) ?? {
key: result.tool.key, key: result.tool.key,
name: null, name: null,
icon: null, icon: null,
}, },
{ fallbackIcon: "🎣" } { fallbackIcon: "🎣" }
); ),
if (result.tool.broken) instancesRemaining: result.tool.instancesRemaining,
return `${base} (agotada)${durabilityBar()}`; broken: result.tool.broken,
if (result.tool.brokenInstance) brokenInstance: result.tool.brokenInstance,
return `${base} (se rompió una instancia, quedan ${ durabilityDelta: result.tool.durabilityDelta,
result.tool.instancesRemaining remaining: result.tool.remaining,
}) (-${result.tool.durabilityDelta ?? 0} dur.)${durabilityBar()}`; max: result.tool.max,
const multi = source: result.tool.toolSource,
result.tool.instancesRemaining && })
result.tool.instancesRemaining > 1
? ` (x${result.tool.instancesRemaining})`
: "";
return `${base}${multi} (-${
result.tool.durabilityDelta ?? 0
} dur.)${durabilityBar()}`;
})()
: "—"; : "—";
const combatSummary = (() => { const combatSummary = result.combat
if (!result.combat) return null; ? combatSummaryRPG({
const c = result.combat; mobs: result.mobs.length,
return `**Combate**\n• Mobs: ${c.mobs.length} | Derrotados: ${c.mobsDefeated}/${result.mobs.length}\n• Daño hecho: ${c.totalDamageDealt} | Daño recibido: ${c.totalDamageTaken}`; mobsDefeated: result.combat.mobsDefeated,
})(); totalDamageDealt: result.combat.totalDamageDealt,
totalDamageTaken: result.combat.totalDamageTaken,
playerStartHp: result.combat.playerStartHp,
playerEndHp: result.combat.playerEndHp,
outcome: result.combat.outcome,
})
: null;
const blocks = [textBlock("# 🎣 Pesca")]; const blocks = [textBlock("# 🎣 Pesca")];

View File

@@ -0,0 +1,54 @@
import type { CommandMessage } from "../../../core/types/commands";
import type Amayo from "../../../core/client";
import { getToolBreaks } from "../../../game/lib/toolBreakLog";
import {
buildDisplay,
dividerBlock,
textBlock,
} from "../../../core/lib/componentsV2";
export const command: CommandMessage = {
name: "tool-breaks",
type: "message",
aliases: ["rupturas", "breaks"],
cooldown: 4,
description:
"Muestra las últimas rupturas de herramientas registradas (memoria).",
usage: "tool-breaks [limite=10]",
run: async (message, args, _client: Amayo) => {
const guildId = message.guild!.id;
const limit = Math.min(50, Math.max(1, parseInt(args[0] || "10")));
const events = getToolBreaks(limit, guildId);
if (!events.length) {
await message.reply(
"No se han registrado rupturas de herramientas todavía."
);
return;
}
const blocks = [
textBlock(`# 🧩 Rupturas de Herramienta (${events.length})`),
dividerBlock(),
];
for (const ev of events) {
const when = new Date(ev.ts).toLocaleTimeString("es-ES", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
blocks.push(
textBlock(`${when}\
Tool: \
\`${ev.toolKey}\`${
ev.brokenInstance ? "Instancia rota" : "Agotada totalmente"
} • Restantes: ${ev.instancesRemaining} • User: <@${ev.userId}>`)
);
blocks.push(dividerBlock({ divider: false, spacing: 1 }));
}
const display = buildDisplay(0x444444, blocks);
await message.reply({ content: "", components: [display] });
},
};

View File

@@ -1,9 +1,9 @@
import { prisma } from '../../core/database/prisma'; import { prisma } from "../../core/database/prisma";
import type { ItemProps } from '../economy/types'; import type { ItemProps } from "../economy/types";
import { ensureUserAndGuildExist } from '../core/userService'; import { ensureUserAndGuildExist } from "../core/userService";
function parseItemProps(json: unknown): ItemProps { function parseItemProps(json: unknown): ItemProps {
if (!json || typeof json !== 'object') return {}; if (!json || typeof json !== "object") return {};
return json as ItemProps; return json as ItemProps;
} }
@@ -27,18 +27,32 @@ export async function getEquipment(userId: string, guildId: string) {
update: {}, update: {},
create: { userId, guildId }, create: { userId, guildId },
}); });
const weapon = eq.weaponItemId ? await prisma.economyItem.findUnique({ where: { id: eq.weaponItemId } }) : null; const weapon = eq.weaponItemId
const armor = eq.armorItemId ? await prisma.economyItem.findUnique({ where: { id: eq.armorItemId } }) : null; ? await prisma.economyItem.findUnique({ where: { id: eq.weaponItemId } })
const cape = eq.capeItemId ? await prisma.economyItem.findUnique({ where: { id: eq.capeItemId } }) : null; : 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; 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 // Asegurar que User y Guild existan antes de crear/actualizar equipment
await ensureUserAndGuildExist(userId, guildId); await ensureUserAndGuildExist(userId, guildId);
const data = slot === 'weapon' ? { weaponItemId: itemId } const data =
: slot === 'armor' ? { armorItemId: itemId } slot === "weapon"
? { weaponItemId: itemId }
: slot === "armor"
? { armorItemId: itemId }
: { capeItemId: itemId }; : { capeItemId: itemId };
return prisma.playerEquipment.upsert({ return prisma.playerEquipment.upsert({
where: { userId_guildId: { userId, guildId } }, where: { userId_guildId: { userId, guildId } },
@@ -54,22 +68,36 @@ export type EffectiveStats = {
hp: number; 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 }; 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 }; if (!inv) return { damageBonus: 0, defenseBonus: 0, maxHpBonus: 0 };
const links = await prisma.inventoryItemMutation.findMany({ where: { inventoryId: inv.id }, include: { mutation: true } }); const links = await prisma.inventoryItemMutation.findMany({
let damageBonus = 0, defenseBonus = 0, maxHpBonus = 0; where: { inventoryId: inv.id },
include: { mutation: true },
});
let damageBonus = 0,
defenseBonus = 0,
maxHpBonus = 0;
for (const l of links) { for (const l of links) {
const eff = (l.mutation.effects as any) || {}; const eff = (l.mutation.effects as any) || {};
if (typeof eff.damageBonus === 'number') damageBonus += eff.damageBonus; if (typeof eff.damageBonus === "number") damageBonus += eff.damageBonus;
if (typeof eff.defenseBonus === 'number') defenseBonus += eff.defenseBonus; if (typeof eff.defenseBonus === "number") defenseBonus += eff.defenseBonus;
if (typeof eff.maxHpBonus === 'number') maxHpBonus += eff.maxHpBonus; if (typeof eff.maxHpBonus === "number") maxHpBonus += eff.maxHpBonus;
} }
return { damageBonus, defenseBonus, 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 state = await ensurePlayerState(userId, guildId);
const { weapon, armor, cape } = await getEquipment(userId, guildId); const { weapon, armor, cape } = await getEquipment(userId, guildId);
const w = parseItemProps(weapon?.props); 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 mutA = await getMutationBonuses(userId, guildId, armor?.id ?? null);
const mutC = await getMutationBonuses(userId, guildId, cape?.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 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); 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 }; 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 c = parseItemProps(cape?.props);
const maxHp = Math.max(1, state.maxHp + (c.maxHpBonus ?? 0)); const maxHp = Math.max(1, state.maxHp + (c.maxHpBonus ?? 0));
const next = Math.min(maxHp, Math.max(0, state.hp + delta)); 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, findItemByKey,
getInventoryEntry, getInventoryEntry,
} from "../economy/service"; } 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 { ItemProps, InventoryState } from "../economy/types";
import type { import type {
LevelRequirements, LevelRequirements,
@@ -95,25 +102,60 @@ async function validateRequirements(
req?: LevelRequirements, req?: LevelRequirements,
toolKey?: string 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; 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 toolKeyUsed = toolKey;
let toolSource: "provided" | "equipped" | "auto" | undefined = undefined;
if (toolKeyUsed) toolSource = "provided";
// Auto-select tool when required and not provided // Auto-select tool when required and not provided
if (!toolKeyUsed && toolReq.required && toolReq.toolType) { if (!toolKeyUsed && toolReq.required && toolReq.toolType) {
toolKeyUsed = // 1. Intentar herramienta equipada en slot weapon si coincide el tipo
(await findBestToolKey(userId, guildId, toolReq.toolType, { 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, minTier: toolReq.minTier,
allowedKeys: toolReq.allowedKeys, allowedKeys: toolReq.allowedKeys,
})) ?? undefined; });
if (best) {
toolKeyUsed = best;
toolSource = "auto";
}
}
} }
// herramienta requerida // herramienta requerida
if (toolReq.required && !toolKeyUsed) if (toolReq.required && !toolKeyUsed)
throw new Error("Se requiere una herramienta adecuada"); throw new Error("Se requiere una herramienta adecuada");
if (!toolKeyUsed) return { toolKeyUsed: undefined }; if (!toolKeyUsed) return { toolKeyUsed: undefined, toolSource };
// verificar herramienta // verificar herramienta
const toolItem = await findItemByKey(guildId, toolKeyUsed); const toolItem = await findItemByKey(guildId, toolKeyUsed);
@@ -131,7 +173,7 @@ async function validateRequirements(
if (toolReq.allowedKeys && !toolReq.allowedKeys.includes(toolKeyUsed)) if (toolReq.allowedKeys && !toolReq.allowedKeys.includes(toolKeyUsed))
throw new Error("Esta herramienta no es válida para esta área"); throw new Error("Esta herramienta no es válida para esta área");
return { toolKeyUsed }; return { toolKeyUsed, toolSource };
} }
async function applyRewards( async function applyRewards(
@@ -261,10 +303,14 @@ async function reduceToolDurability(
}); });
// Placeholder: logging de ruptura (migrar a ToolBreakLog futuro) // Placeholder: logging de ruptura (migrar a ToolBreakLog futuro)
if (brokenInstance) { if (brokenInstance) {
// eslint-disable-next-line no-console logToolBreak({
console.log( ts: Date.now(),
`[tool-break] user=${userId} guild=${guildId} toolKey=${toolKey}` userId,
); guildId,
toolKey,
brokenInstance: !broken, // true = solo una instancia
instancesRemaining,
});
} }
return { return {
broken, broken,
@@ -329,76 +375,141 @@ export async function runMinigame(
max: t.max, max: t.max,
brokenInstance: t.brokenInstance, brokenInstance: t.brokenInstance,
instancesRemaining: t.instancesRemaining, instancesRemaining: t.instancesRemaining,
toolSource: reqRes.toolSource ?? (opts?.toolKey ? "provided" : "auto"),
}; };
} }
// --- Combate Básico (placeholder) --- // (Eliminado combate placeholder; sustituido por sistema integrado más abajo)
// Objetivo: procesar mobs spawneados y generar resumen estadístico simple. // --- Combate Integrado con Equipo y HP Persistente ---
// Futuro: stats jugador, equipo, habilidades. Ahora: valores fijos pseudo-aleatorios.
let combatSummary: CombatSummary | undefined; let combatSummary: CombatSummary | undefined;
if (mobsSpawned.length > 0) { 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"] = []; const mobLogs: CombatSummary["mobs"] = [];
let totalDealt = 0; let totalDealt = 0;
let totalTaken = 0; let totalTaken = 0;
let defeated = 0; let totalMobsDefeated = 0;
const basePlayerAtk = 5 + Math.random() * 3; // placeholder // Variación de ±20%
const basePlayerDef = 1 + Math.random() * 2; // placeholder const variance = (base: number) => {
const factor = 0.8 + Math.random() * 0.4; // 0.8 - 1.2
return base * factor;
};
for (const mobKey of mobsSpawned) { for (const mobKey of mobsSpawned) {
// Se podría leer tabla real de mobs (stats json en prisma.mob) en futuro. if (currentHp <= 0) break; // jugador derrotado antes de iniciar este mob
// Por ahora HP aleatorio controlado. // Stats simples del mob (placeholder mejorable con tabla real)
const maxHp = 8 + Math.floor(Math.random() * 8); // 8-15 const mobBaseHp = 10 + Math.floor(Math.random() * 6); // 10-15
let hp = maxHp; let mobHp = mobBaseHp;
const rounds = [] as any[]; const rounds: any[] = [];
let roundIndex = 1; let round = 1;
let mobTotalDealt = 0; let mobDamageDealt = 0; // daño que jugador hace a este mob
let mobTotalTaken = 0; let mobDamageTakenFromMob = 0; // daño que jugador recibe de este mob
while (hp > 0 && roundIndex <= 10) { while (mobHp > 0 && currentHp > 0 && round <= 12) {
// limite de 10 rondas por mob // Daño jugador -> mob
const playerDamage = Math.max( const playerRaw = variance(eff.damage || 1) + 1; // asegurar >=1
1, const playerDamage = Math.max(1, Math.round(playerRaw));
Math.round(basePlayerAtk + Math.random() * 2) mobHp -= playerDamage;
); mobDamageDealt += playerDamage;
hp -= playerDamage;
mobTotalDealt += playerDamage;
totalDealt += playerDamage; totalDealt += playerDamage;
let playerTaken = 0; let playerTaken = 0;
if (hp > 0) { if (mobHp > 0) {
// Mob sólo pega si sigue vivo const mobAtkBase = 3 + Math.random() * 4; // 3-7
const mobAtk = 2 + Math.random() * 3; // 2-5 const mobAtk = variance(mobAtkBase);
const mitigated = Math.max(0, mobAtk - basePlayerDef * 0.5); // Mitigación por defensa => defensa reduce linealmente hasta 60% cap
playerTaken = Math.round(mitigated); 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; totalTaken += playerTaken;
mobTotalTaken += playerTaken; }
} }
rounds.push({ rounds.push({
mobKey, mobKey,
round: roundIndex, round,
playerDamageDealt: playerDamage, playerDamageDealt: playerDamage,
playerDamageTaken: playerTaken, playerDamageTaken: playerTaken,
mobRemainingHp: Math.max(0, hp), mobRemainingHp: Math.max(0, mobHp),
mobDefeated: hp <= 0, mobDefeated: mobHp <= 0,
}); });
if (hp <= 0) { if (mobHp <= 0) {
defeated++; totalMobsDefeated++;
break; break;
} }
roundIndex++; if (currentHp <= 0) break;
round++;
} }
mobLogs.push({ mobLogs.push({
mobKey, mobKey,
maxHp, maxHp: mobBaseHp,
defeated: hp <= 0, defeated: mobHp <= 0,
totalDamageDealt: mobTotalDealt, totalDamageDealt: mobDamageDealt,
totalDamageTakenFromMob: mobTotalTaken, totalDamageTakenFromMob: mobDamageTakenFromMob,
rounds, 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 = { combatSummary = {
mobs: mobLogs, mobs: mobLogs,
totalDamageDealt: totalDealt, totalDamageDealt: totalDealt,
totalDamageTaken: totalTaken, totalDamageTaken: totalTaken,
mobsDefeated: defeated, mobsDefeated: totalMobsDefeated,
victory: defeated === mobsSpawned.length, victory,
playerStartHp: startHp,
playerEndHp: endHp,
outcome: victory ? "victory" : "defeat",
}; };
} }

View File

@@ -59,6 +59,7 @@ export type RunResult = {
max?: number; // durabilidad máxima configurada max?: number; // durabilidad máxima configurada
brokenInstance?: boolean; // true si solo se rompió una instancia brokenInstance?: boolean; // true si solo se rompió una instancia
instancesRemaining?: number; // instancias que quedan después del uso 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ó combat?: CombatSummary; // resumen de combate si hubo mobs y se procesó
}; };
@@ -88,4 +89,7 @@ export type CombatSummary = {
totalDamageTaken: number; totalDamageTaken: number;
mobsDefeated: number; mobsDefeated: number;
victory: boolean; // true si el jugador sobrevivió a todos los mobs 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 };
}