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).
## 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.
- Asegúrate de usarlo en un canal de texto compatible.
- 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.
```
## 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
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,
textBlock,
} from "../../../core/lib/componentsV2";
import { formatToolLabel, combatSummaryRPG } from "../../../game/lib/rpgFormat";
import { buildAreaMetadataBlocks } from "./_helpers";
const MINING_ACCENT = 0xc27c0e;
@@ -106,58 +107,38 @@ export const command: CommandMessage = {
? 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 base = formatItemLabel(
? formatToolLabel({
key: result.tool.key,
displayName: formatItemLabel(
rewardItems.get(result.tool.key) ?? {
key: result.tool.key,
name: null,
icon: null,
},
{ fallbackIcon: "🔧" }
);
if (result.tool.broken) {
return `${base} (agotada)${durabilityBar()}`;
}
if (result.tool.brokenInstance) {
return `${base} (se rompió una instancia, quedan ${
result.tool.instancesRemaining
}) (-${result.tool.durabilityDelta ?? 0} dur.)${durabilityBar()}`;
}
const multi =
result.tool.instancesRemaining &&
result.tool.instancesRemaining > 1
? ` (x${result.tool.instancesRemaining})`
: "";
return `${base}${multi} (-${
result.tool.durabilityDelta ?? 0
} dur.)${durabilityBar()}`;
})()
),
instancesRemaining: result.tool.instancesRemaining,
broken: result.tool.broken,
brokenInstance: result.tool.brokenInstance,
durabilityDelta: result.tool.durabilityDelta,
remaining: result.tool.remaining,
max: result.tool.max,
source: result.tool.toolSource,
})
: "—";
const combatSummary = (() => {
if (!result.combat) return null;
const c = result.combat;
return `**Combate**\n• Mobs: ${c.mobs.length} | Derrotados: ${c.mobsDefeated}/${result.mobs.length}\n• Daño hecho: ${c.totalDamageDealt} | Daño recibido: ${c.totalDamageTaken}`;
})();
const combatSummary = result.combat
? combatSummaryRPG({
mobs: result.mobs.length,
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")];

View File

@@ -19,6 +19,7 @@ import {
dividerBlock,
textBlock,
} from "../../../core/lib/componentsV2";
import { formatToolLabel, combatSummaryRPG } from "../../../game/lib/rpgFormat";
import { buildAreaMetadataBlocks } from "./_helpers";
const FIGHT_ACCENT = 0x992d22;
@@ -115,54 +116,37 @@ export const command: CommandMessage = {
const mobsLines = result.mobs.length
? 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 base = formatItemLabel(
? formatToolLabel({
key: result.tool.key,
displayName: formatItemLabel(
rewardItems.get(result.tool.key) ?? {
key: result.tool.key,
name: null,
icon: null,
},
{ fallbackIcon: "🗡️" }
);
if (result.tool.broken)
return `${base} (agotada)${durabilityBar()}`;
if (result.tool.brokenInstance)
return `${base} (se rompió una instancia, quedan ${
result.tool.instancesRemaining
}) (-${result.tool.durabilityDelta ?? 0} dur.)${durabilityBar()}`;
const multi =
result.tool.instancesRemaining &&
result.tool.instancesRemaining > 1
? ` (x${result.tool.instancesRemaining})`
: "";
return `${base}${multi} (-${
result.tool.durabilityDelta ?? 0
} dur.)${durabilityBar()}`;
})()
),
instancesRemaining: result.tool.instancesRemaining,
broken: result.tool.broken,
brokenInstance: result.tool.brokenInstance,
durabilityDelta: result.tool.durabilityDelta,
remaining: result.tool.remaining,
max: result.tool.max,
source: result.tool.toolSource,
})
: "—";
const combatSummary = (() => {
if (!result.combat) return null;
const c = result.combat;
return `**Combate**\n• Mobs: ${c.mobs.length} | Derrotados: ${c.mobsDefeated}/${result.mobs.length}\n• Daño hecho: ${c.totalDamageDealt} | Daño recibido: ${c.totalDamageTaken}`;
})();
const combatSummary = result.combat
? combatSummaryRPG({
mobs: result.mobs.length,
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")];

View File

@@ -19,6 +19,7 @@ import {
dividerBlock,
textBlock,
} from "../../../core/lib/componentsV2";
import { formatToolLabel, combatSummaryRPG } from "../../../game/lib/rpgFormat";
import { buildAreaMetadataBlocks } from "./_helpers";
const FISHING_ACCENT = 0x1abc9c;
@@ -101,54 +102,37 @@ export const command: CommandMessage = {
const mobsLines = result.mobs.length
? 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 base = formatItemLabel(
? formatToolLabel({
key: result.tool.key,
displayName: formatItemLabel(
rewardItems.get(result.tool.key) ?? {
key: result.tool.key,
name: null,
icon: null,
},
{ fallbackIcon: "🎣" }
);
if (result.tool.broken)
return `${base} (agotada)${durabilityBar()}`;
if (result.tool.brokenInstance)
return `${base} (se rompió una instancia, quedan ${
result.tool.instancesRemaining
}) (-${result.tool.durabilityDelta ?? 0} dur.)${durabilityBar()}`;
const multi =
result.tool.instancesRemaining &&
result.tool.instancesRemaining > 1
? ` (x${result.tool.instancesRemaining})`
: "";
return `${base}${multi} (-${
result.tool.durabilityDelta ?? 0
} dur.)${durabilityBar()}`;
})()
),
instancesRemaining: result.tool.instancesRemaining,
broken: result.tool.broken,
brokenInstance: result.tool.brokenInstance,
durabilityDelta: result.tool.durabilityDelta,
remaining: result.tool.remaining,
max: result.tool.max,
source: result.tool.toolSource,
})
: "—";
const combatSummary = (() => {
if (!result.combat) return null;
const c = result.combat;
return `**Combate**\n• Mobs: ${c.mobs.length} | Derrotados: ${c.mobsDefeated}/${result.mobs.length}\n• Daño hecho: ${c.totalDamageDealt} | Daño recibido: ${c.totalDamageTaken}`;
})();
const combatSummary = result.combat
? combatSummaryRPG({
mobs: result.mobs.length,
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")];

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