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:
47
README.MD
47
README.MD
@@ -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)
|
||||||
|
|
||||||
|
|||||||
66
src/commands/messages/game/combatehistorial.ts
Normal file
66
src/commands/messages/game/combatehistorial.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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")];
|
||||||
|
|
||||||
|
|||||||
@@ -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")];
|
||||||
|
|
||||||
|
|||||||
@@ -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")];
|
||||||
|
|
||||||
|
|||||||
54
src/commands/messages/game/toolbreaks.ts
Normal file
54
src/commands/messages/game/toolbreaks.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
101
src/game/lib/rpgFormat.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Utilidades de formato visual estilo RPG para comandos y resúmenes.
|
||||||
|
// Centraliza lógica repetida (barras de corazones, durabilidad, etiquetas de herramientas).
|
||||||
|
|
||||||
|
export function heartsBar(
|
||||||
|
current: number,
|
||||||
|
max: number,
|
||||||
|
opts?: { segments?: number; fullChar?: string; emptyChar?: string }
|
||||||
|
) {
|
||||||
|
const segments = opts?.segments ?? Math.min(20, max); // límite visual
|
||||||
|
const full = opts?.fullChar ?? "❤";
|
||||||
|
const empty = opts?.emptyChar ?? "♡";
|
||||||
|
const clampedMax = Math.max(1, max);
|
||||||
|
const ratio = current / clampedMax;
|
||||||
|
const filled = Math.max(0, Math.min(segments, Math.round(ratio * segments)));
|
||||||
|
return full.repeat(filled) + empty.repeat(segments - filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function durabilityBar(remaining: number, max: number, segments = 10) {
|
||||||
|
const safeMax = Math.max(1, max);
|
||||||
|
const ratio = Math.max(0, Math.min(1, remaining / safeMax));
|
||||||
|
const filled = Math.round(ratio * segments);
|
||||||
|
const bar = Array.from({ length: segments })
|
||||||
|
.map((_, i) => (i < filled ? "█" : "░"))
|
||||||
|
.join("");
|
||||||
|
return `[${bar}] ${Math.max(0, remaining)}/${safeMax}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolLabel(params: {
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
instancesRemaining?: number | null;
|
||||||
|
broken?: boolean;
|
||||||
|
brokenInstance?: boolean;
|
||||||
|
durabilityDelta?: number | null;
|
||||||
|
remaining?: number | null;
|
||||||
|
max?: number | null;
|
||||||
|
source?: string | null;
|
||||||
|
fallbackIcon?: string;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
key,
|
||||||
|
displayName,
|
||||||
|
instancesRemaining,
|
||||||
|
broken,
|
||||||
|
brokenInstance,
|
||||||
|
durabilityDelta,
|
||||||
|
remaining,
|
||||||
|
max,
|
||||||
|
source,
|
||||||
|
fallbackIcon = "🔧",
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const multi =
|
||||||
|
instancesRemaining && instancesRemaining > 1
|
||||||
|
? ` (x${instancesRemaining})`
|
||||||
|
: "";
|
||||||
|
const base = `${displayName || key}${multi}`;
|
||||||
|
let status = "";
|
||||||
|
if (broken) status = " (agotada)";
|
||||||
|
else if (brokenInstance)
|
||||||
|
status = ` (instancia rota, quedan ${instancesRemaining})`;
|
||||||
|
const delta = durabilityDelta != null ? ` (-${durabilityDelta} dur.)` : "";
|
||||||
|
const dur =
|
||||||
|
remaining != null && max != null
|
||||||
|
? `\nDurabilidad: ${durabilityBar(remaining, max)}`
|
||||||
|
: "";
|
||||||
|
const src = source ? ` \`(${source})\`` : "";
|
||||||
|
return `${base}${status}${delta}${src}${dur}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function outcomeLabel(outcome?: "victory" | "defeat") {
|
||||||
|
if (!outcome) return "";
|
||||||
|
return outcome === "victory" ? "🏆 Victoria" : "💀 Derrota";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combatSummaryRPG(c: {
|
||||||
|
mobs: number;
|
||||||
|
mobsDefeated: number;
|
||||||
|
totalDamageDealt: number;
|
||||||
|
totalDamageTaken: number;
|
||||||
|
playerStartHp?: number | null;
|
||||||
|
playerEndHp?: number | null;
|
||||||
|
outcome?: "victory" | "defeat";
|
||||||
|
maxRefHp?: number; // para cálculo visual si difiere
|
||||||
|
}) {
|
||||||
|
const header = `**Combate (${outcomeLabel(c.outcome)})**`;
|
||||||
|
const lines = [
|
||||||
|
`• Mobs: ${c.mobs} | Derrotados: ${c.mobsDefeated}/${c.mobs}`,
|
||||||
|
`• Daño hecho: ${c.totalDamageDealt} | Daño recibido: ${c.totalDamageTaken}`,
|
||||||
|
];
|
||||||
|
if (c.playerStartHp != null && c.playerEndHp != null) {
|
||||||
|
const maxHp = c.maxRefHp || Math.max(c.playerStartHp, c.playerEndHp);
|
||||||
|
lines.push(
|
||||||
|
`• HP: ${c.playerStartHp} → ${c.playerEndHp} ${heartsBar(
|
||||||
|
c.playerEndHp,
|
||||||
|
maxHp
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${header}\n${lines.join("\n")}`;
|
||||||
|
}
|
||||||
33
src/game/lib/toolBreakLog.ts
Normal file
33
src/game/lib/toolBreakLog.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Logger en memoria de rupturas de herramientas.
|
||||||
|
// Reemplazable en el futuro por una tabla ToolBreakLog.
|
||||||
|
|
||||||
|
export interface ToolBreakEvent {
|
||||||
|
ts: number;
|
||||||
|
userId: string;
|
||||||
|
guildId: string;
|
||||||
|
toolKey: string;
|
||||||
|
brokenInstance: boolean; // true si fue una instancia, false si se agotó totalmente (última)
|
||||||
|
instancesRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_EVENTS = 200;
|
||||||
|
const buffer: ToolBreakEvent[] = [];
|
||||||
|
|
||||||
|
export function logToolBreak(ev: ToolBreakEvent) {
|
||||||
|
buffer.unshift(ev);
|
||||||
|
if (buffer.length > MAX_EVENTS) buffer.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolBreaks(
|
||||||
|
limit = 20,
|
||||||
|
guildFilter?: string,
|
||||||
|
userFilter?: string
|
||||||
|
) {
|
||||||
|
return buffer
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
(!guildFilter || e.guildId === guildFilter) &&
|
||||||
|
(!userFilter || e.userId === userFilter)
|
||||||
|
)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
@@ -5,6 +5,13 @@ import {
|
|||||||
findItemByKey,
|
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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
72
src/game/mobs/mobData.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Definición declarativa de mobs (scaffolding)
|
||||||
|
// Futuro: migrar a tabla prisma.mob enriquecida o cache Appwrite.
|
||||||
|
|
||||||
|
export interface BaseMobDefinition {
|
||||||
|
key: string; // identificador único
|
||||||
|
name: string; // nombre visible
|
||||||
|
tier: number; // escala de dificultad base
|
||||||
|
base: {
|
||||||
|
hp: number;
|
||||||
|
attack: number;
|
||||||
|
defense?: number;
|
||||||
|
};
|
||||||
|
scaling?: {
|
||||||
|
hpPerLevel?: number; // incremento por nivel de área
|
||||||
|
attackPerLevel?: number;
|
||||||
|
defensePerLevel?: number;
|
||||||
|
hpMultiplierPerTier?: number; // multiplicador adicional por tier
|
||||||
|
};
|
||||||
|
tags?: string[]; // p.ej. ['undead','beast']
|
||||||
|
rewardMods?: {
|
||||||
|
coinMultiplier?: number;
|
||||||
|
extraDropChance?: number; // 0-1
|
||||||
|
};
|
||||||
|
behavior?: {
|
||||||
|
maxRounds?: number; // override límite de rondas
|
||||||
|
aggressive?: boolean; // si ataca siempre
|
||||||
|
critChance?: number; // 0-1
|
||||||
|
critMultiplier?: number; // default 1.5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejemplos iniciales - se pueden ir expandiendo
|
||||||
|
export const MOB_DEFINITIONS: BaseMobDefinition[] = [
|
||||||
|
{
|
||||||
|
key: "slime.green",
|
||||||
|
name: "Slime Verde",
|
||||||
|
tier: 1,
|
||||||
|
base: { hp: 18, attack: 4 },
|
||||||
|
scaling: { hpPerLevel: 3, attackPerLevel: 0.5 },
|
||||||
|
tags: ["slime"],
|
||||||
|
rewardMods: { coinMultiplier: 0.9 },
|
||||||
|
behavior: { maxRounds: 12, aggressive: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "skeleton.basic",
|
||||||
|
name: "Esqueleto",
|
||||||
|
tier: 2,
|
||||||
|
base: { hp: 30, attack: 6, defense: 1 },
|
||||||
|
scaling: { hpPerLevel: 4, attackPerLevel: 0.8, defensePerLevel: 0.2 },
|
||||||
|
tags: ["undead"],
|
||||||
|
rewardMods: { coinMultiplier: 1.1, extraDropChance: 0.05 },
|
||||||
|
behavior: { aggressive: true, critChance: 0.05, critMultiplier: 1.5 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findMobDef(key: string) {
|
||||||
|
return MOB_DEFINITIONS.find((m) => m.key === key) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMobStats(def: BaseMobDefinition, areaLevel: number) {
|
||||||
|
const lvl = Math.max(1, areaLevel);
|
||||||
|
const s = def.scaling || {};
|
||||||
|
const hp = Math.round(def.base.hp + (s.hpPerLevel ?? 0) * (lvl - 1));
|
||||||
|
const atk = +(def.base.attack + (s.attackPerLevel ?? 0) * (lvl - 1)).toFixed(
|
||||||
|
2
|
||||||
|
);
|
||||||
|
const defVal = +(
|
||||||
|
(def.base.defense ?? 0) +
|
||||||
|
(s.defensePerLevel ?? 0) * (lvl - 1)
|
||||||
|
).toFixed(2);
|
||||||
|
return { hp, attack: atk, defense: defVal };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user