From 1c086ec025b3dcacf0941c3e744fa6d889e13144 Mon Sep 17 00:00:00 2001 From: shni Date: Thu, 9 Oct 2025 00:12:56 -0500 Subject: [PATCH] feat: agregar barra de durabilidad y resumen de combate en comandos de mina, pelear y pescar; implementar comando para inspeccionar herramientas --- src/commands/messages/game/mina.ts | 59 +++++++++--- src/commands/messages/game/pelear.ts | 53 ++++++++--- src/commands/messages/game/pescar.ts | 53 ++++++++--- src/commands/messages/game/toolinfo.ts | 93 +++++++++++++++++++ src/game/economy/service.ts | 12 ++- src/game/minigames/service.ts | 123 +++++++++++++++++++++++-- src/game/minigames/types.ts | 50 +++++++++- 7 files changed, 393 insertions(+), 50 deletions(-) create mode 100644 src/commands/messages/game/toolinfo.ts diff --git a/src/commands/messages/game/mina.ts b/src/commands/messages/game/mina.ts index 508f4e8..6cc0878 100644 --- a/src/commands/messages/game/mina.ts +++ b/src/commands/messages/game/mina.ts @@ -105,21 +105,52 @@ 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 - ? `${formatItemLabel( - rewardItems.get(result.tool.key) ?? { - key: result.tool.key, - name: null, - icon: null, - }, - { fallbackIcon: "🔧" } - )}${ - result.tool.broken - ? " (rota)" - : ` (-${result.tool.durabilityDelta ?? 0} dur.)` - }` + ? (() => { + const base = 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()}`; + })() : "—"; + 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 blocks = [textBlock("# ⛏️ Mina")]; if (globalNotice) { @@ -141,6 +172,10 @@ export const command: CommandMessage = { blocks.push(textBlock(`**Recompensas**\n${rewardLines}`)); blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(textBlock(`**Mobs**\n${mobsLines}`)); + if (combatSummary) { + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(combatSummary)); + } // Añadir metadata del área (imagen/descripcion) si existe const metaBlocks = buildAreaMetadataBlocks(area); diff --git a/src/commands/messages/game/pelear.ts b/src/commands/messages/game/pelear.ts index 3e8c923..bd29846 100644 --- a/src/commands/messages/game/pelear.ts +++ b/src/commands/messages/game/pelear.ts @@ -115,20 +115,45 @@ 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 - ? `${formatItemLabel( - rewardItems.get(result.tool.key) ?? { - key: result.tool.key, - name: null, - icon: null, - }, - { fallbackIcon: "🗡️" } - )}${ - result.tool.broken - ? " (rota)" - : ` (-${result.tool.durabilityDelta ?? 0} dur.)` - }` + ? (() => { + const base = 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()}`; + })() : "—"; + 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 blocks = [textBlock("# ⚔️ Arena")]; @@ -151,6 +176,10 @@ export const command: CommandMessage = { blocks.push(textBlock(`**Recompensas**\n${rewardLines}`)); blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(textBlock(`**Enemigos**\n${mobsLines}`)); + if (combatSummary) { + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(combatSummary)); + } // Añadir metadata del área const metaBlocks = buildAreaMetadataBlocks(area); diff --git a/src/commands/messages/game/pescar.ts b/src/commands/messages/game/pescar.ts index 6b8e7c7..4c0bbf7 100644 --- a/src/commands/messages/game/pescar.ts +++ b/src/commands/messages/game/pescar.ts @@ -101,20 +101,45 @@ 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 - ? `${formatItemLabel( - rewardItems.get(result.tool.key) ?? { - key: result.tool.key, - name: null, - icon: null, - }, - { fallbackIcon: "🎣" } - )}${ - result.tool.broken - ? " (rota)" - : ` (-${result.tool.durabilityDelta ?? 0} dur.)` - }` + ? (() => { + const base = 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()}`; + })() : "—"; + 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 blocks = [textBlock("# 🎣 Pesca")]; @@ -137,6 +162,10 @@ export const command: CommandMessage = { blocks.push(textBlock(`**Recompensas**\n${rewardLines}`)); blocks.push(dividerBlock({ divider: false, spacing: 1 })); blocks.push(textBlock(`**Mobs**\n${mobsLines}`)); + if (combatSummary) { + blocks.push(dividerBlock({ divider: false, spacing: 1 })); + blocks.push(textBlock(combatSummary)); + } // Añadir metadata del área const metaBlocks = buildAreaMetadataBlocks(area); diff --git a/src/commands/messages/game/toolinfo.ts b/src/commands/messages/game/toolinfo.ts new file mode 100644 index 0000000..5b61976 --- /dev/null +++ b/src/commands/messages/game/toolinfo.ts @@ -0,0 +1,93 @@ +import type { CommandMessage } from "../../../core/types/commands"; +import type Amayo from "../../../core/client"; +import { getInventoryEntry } from "../../../game/economy/service"; +import { + buildDisplay, + textBlock, + dividerBlock, +} from "../../../core/lib/componentsV2"; +import { formatItemLabel, sendDisplayReply } from "./_helpers"; + +// Inspecciona la durabilidad de una herramienta (no apilable) mostrando barra. + +function parseJSON(v: unknown): T | null { + if (!v || typeof v !== "object") return null; + return v as T; +} + +export const command: CommandMessage = { + name: "tool-info", + type: "message", + aliases: ["toolinfo", "herramienta", "inspectar", "inspeccionar"], + cooldown: 3, + description: "Muestra la durabilidad restante de una herramienta por su key.", + usage: "tool-info ", + run: async (message, args, _client: Amayo) => { + const userId = message.author.id; + const guildId = message.guild!.id; + const key = args[0]; + if (!key) { + await message.reply( + "⚠️ Debes indicar la key del item. Ej: `tool-info tool.pickaxe.basic`" + ); + return; + } + try { + const { item, entry } = await getInventoryEntry(userId, guildId, key); + if (!entry || !item) { + await message.reply("❌ No tienes este ítem en tu inventario."); + return; + } + const props = parseJSON(item.props) ?? {}; + const breakable = props.breakable; + if (!breakable || breakable.enabled === false) { + await message.reply("ℹ️ Este ítem no tiene durabilidad activa."); + return; + } + if (item.stackable) { + await message.reply(`ℹ️ Ítem apilable. Cantidad: ${entry.quantity}`); + return; + } + const state = parseJSON(entry.state) ?? {}; + const instances: any[] = Array.isArray(state.instances) + ? state.instances + : []; + const max = Math.max(1, breakable.maxDurability ?? 1); + const label = formatItemLabel( + { key: item.key, name: item.name, icon: item.icon }, + { fallbackIcon: "🛠️" } + ); + const renderBar = (cur: number) => { + const ratio = cur / max; + const totalSegs = 20; + const filled = Math.round(ratio * totalSegs); + return Array.from({ length: totalSegs }) + .map((_, i) => (i < filled ? "█" : "░")) + .join(""); + }; + const durLines = instances.length + ? instances + .map((inst, idx) => { + const cur = Math.min( + Math.max(0, inst?.durability ?? max), + max + ); + return `#${idx + 1} [${renderBar(cur)}] ${cur}/${max}`; + }) + .join("\n") + : "(sin instancias)"; + const blocks = [ + textBlock("# 🔍 Herramienta"), + dividerBlock(), + textBlock(`**Item:** ${label}`), + textBlock(`Instancias: ${instances.length}`), + textBlock(durLines), + ]; + const accent = 0x95a5a6; + const display = buildDisplay(accent, blocks); + await sendDisplayReply(message, display); + } catch (e: any) { + await message.reply(`❌ No se pudo inspeccionar: ${e?.message ?? e}`); + } + }, +}; diff --git a/src/game/economy/service.ts b/src/game/economy/service.ts index 3e607b0..a19858d 100644 --- a/src/game/economy/service.ts +++ b/src/game/economy/service.ts @@ -161,7 +161,17 @@ export async function addItemByKey( 0, Math.min(qty, Math.max(0, max - state.instances.length)) ); - for (let i = 0; i < canAdd; i++) state.instances.push({}); + // Inicializar durabilidad si corresponde + const props = parseItemProps(item.props); + const breakable = props.breakable; + const maxDurability = breakable?.enabled !== false ? breakable?.maxDurability : undefined; + for (let i = 0; i < canAdd; i++) { + if (maxDurability && maxDurability > 0) { + state.instances.push({ durability: maxDurability }); + } else { + state.instances.push({}); + } + } const updated = await prisma.inventoryEntry.update({ where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, data: { diff --git a/src/game/minigames/service.ts b/src/game/minigames/service.ts index 1f29f44..1a43e9e 100644 --- a/src/game/minigames/service.ts +++ b/src/game/minigames/service.ts @@ -12,6 +12,7 @@ import type { RunResult, RewardsTable, MobsTable, + CombatSummary, } from "./types"; import type { Prisma } from "@prisma/client"; @@ -178,12 +179,27 @@ async function reduceToolDurability( toolKey: string ) { const { item, entry } = await getInventoryEntry(userId, guildId, toolKey); - if (!entry) return { broken: false, delta: 0 } as const; + if (!entry) + return { + broken: false, + brokenInstance: false, + delta: 0, + remaining: undefined, + max: undefined, + instancesRemaining: 0, + } as const; const props = parseItemProps(item.props); const breakable = props.breakable; // Si el item no es breakable o la durabilidad está deshabilitada, no hacemos nada if (!breakable || breakable.enabled === false) { - return { broken: false, delta: 0 } as const; + return { + broken: false, + brokenInstance: false, + delta: 0, + remaining: undefined, + max: undefined, + instancesRemaining: entry.quantity ?? 0, + } as const; } // Valores base @@ -197,30 +213,38 @@ async function reduceToolDurability( if (item.stackable) { // Herramientas deberían ser no apilables; si lo son, solo decrementamos cantidad como fallback const consumed = Math.min(1, entry.quantity); + let broken = false; if (consumed > 0) { - await prisma.inventoryEntry.update({ + const updated = await prisma.inventoryEntry.update({ where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, data: { quantity: { decrement: consumed } }, }); + // Consideramos "rota" sólo si después de consumir ya no queda ninguna unidad + broken = (updated.quantity ?? 0) <= 0; } - return { broken: consumed > 0, delta } as const; + return { broken, brokenInstance: broken, delta, remaining: undefined, max: maxConfigured, instancesRemaining: broken ? 0 : (entry.quantity ?? 1) - 1 } as const; } const state = parseInvState(entry.state); state.instances ??= [{}]; if (state.instances.length === 0) state.instances.push({}); + // Seleccionar instancia: ahora usamos la primera, en futuro se puede elegir la de mayor durabilidad restante const inst = state.instances[0]; const max = maxConfigured; // ya calculado arriba + // Si la instancia no tiene durabilidad inicial, la inicializamos + if (inst.durability == null) (inst as any).durability = max; const current = Math.min(Math.max(0, inst.durability ?? max), max); const next = current - delta; - let broken = false; + let brokenInstance = false; if (next <= 0) { - // romper: eliminar instancia + // romper sólo esta instancia state.instances.shift(); - broken = true; + brokenInstance = true; } else { (inst as any).durability = next; state.instances[0] = inst; } + const instancesRemaining = state.instances.length; + const broken = instancesRemaining === 0; // Ítem totalmente agotado await prisma.inventoryEntry.update({ where: { userId_guildId_itemId: { userId, guildId, itemId: item.id } }, data: { @@ -228,7 +252,14 @@ async function reduceToolDurability( quantity: state.instances.length, }, }); - return { broken, delta } as const; + // 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}` + ); + } + return { broken, brokenInstance, delta, remaining: broken ? 0 : next, max, instancesRemaining } as const; } export { reduceToolDurability }; @@ -280,6 +311,80 @@ export async function runMinigame( key: reqRes.toolKeyUsed, durabilityDelta: t.delta, broken: t.broken, + remaining: t.remaining, + max: t.max, + brokenInstance: t.brokenInstance, + instancesRemaining: t.instancesRemaining, + }; + } + + // --- Combate Básico (placeholder) --- + // Objetivo: procesar mobs spawneados y generar resumen estadístico simple. + // Futuro: stats jugador, equipo, habilidades. Ahora: valores fijos pseudo-aleatorios. + let combatSummary: CombatSummary | undefined; + if (mobsSpawned.length > 0) { + 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 + 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; + 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; + } + rounds.push({ + mobKey, + round: roundIndex, + playerDamageDealt: playerDamage, + playerDamageTaken: playerTaken, + mobRemainingHp: Math.max(0, hp), + mobDefeated: hp <= 0, + }); + if (hp <= 0) { + defeated++; + break; + } + roundIndex++; + } + mobLogs.push({ + mobKey, + maxHp, + defeated: hp <= 0, + totalDamageDealt: mobTotalDealt, + totalDamageTakenFromMob: mobTotalTaken, + rounds, + }); + } + combatSummary = { + mobs: mobLogs, + totalDamageDealt: totalDealt, + totalDamageTaken: totalTaken, + mobsDefeated: defeated, + victory: defeated === mobsSpawned.length, }; } @@ -288,6 +393,7 @@ export async function runMinigame( rewards: delivered, mobs: mobsSpawned, tool: toolInfo, + combat: combatSummary, notes: "auto", } as unknown as Prisma.InputJsonValue; @@ -334,6 +440,7 @@ export async function runMinigame( rewards: delivered, mobs: mobsSpawned, tool: toolInfo, + combat: combatSummary, }; } diff --git a/src/game/minigames/types.ts b/src/game/minigames/types.ts index 257581b..903717e 100644 --- a/src/game/minigames/types.ts +++ b/src/game/minigames/types.ts @@ -4,7 +4,7 @@ export type ToolRequirement = { required?: boolean; // si se requiere herramienta toolType?: string; // 'pickaxe' | 'rod' | 'sword' | ... - minTier?: number; // nivel mínimo de herramienta + minTier?: number; // nivel mínimo de herramienta allowedKeys?: string[]; // lista blanca de item keys específicos }; @@ -15,8 +15,8 @@ export type LevelRequirements = { }; export type WeightedReward = - | { type: 'coins'; amount: number; weight: number } - | { type: 'item'; itemKey: string; qty: number; weight: number }; + | { type: "coins"; amount: number; weight: number } + | { type: "item"; itemKey: string; qty: number; weight: number }; export type RewardsTable = { draws?: number; // cuántas extracciones realizar (default 1) @@ -44,8 +44,48 @@ export type RunMinigameOptions = { export type RunResult = { success: boolean; - rewards: Array<{ type: 'coins' | 'item'; amount?: number; itemKey?: string; qty?: number }>; + rewards: Array<{ + type: "coins" | "item"; + amount?: number; + itemKey?: string; + qty?: number; + }>; mobs: string[]; // keys de mobs spawneados - tool?: { key?: string; durabilityDelta?: number; broken?: boolean }; + tool?: { + key?: string; + durabilityDelta?: number; // cuanto se redujo en esta ejecución + broken?: boolean; // si se rompió en este uso + remaining?: number; // durabilidad restante después de aplicar delta (si aplica) + max?: number; // durabilidad máxima configurada + brokenInstance?: boolean; // true si solo se rompió una instancia + instancesRemaining?: number; // instancias que quedan después del uso + }; + combat?: CombatSummary; // resumen de combate si hubo mobs y se procesó }; +// --- Combate Básico --- +export type CombatRound = { + mobKey: string; + round: number; + playerDamageDealt: number; // daño infligido al mob en esta ronda + playerDamageTaken: number; // daño recibido del mob en esta ronda + mobRemainingHp: number; // hp restante del mob tras la ronda + mobDefeated?: boolean; +}; + +export type CombatMobLog = { + mobKey: string; + maxHp: number; + defeated: boolean; + totalDamageDealt: number; + totalDamageTakenFromMob: number; // daño que el jugador recibió de este mob + rounds: CombatRound[]; +}; + +export type CombatSummary = { + mobs: CombatMobLog[]; + totalDamageDealt: number; + totalDamageTaken: number; + mobsDefeated: number; + victory: boolean; // true si el jugador sobrevivió a todos los mobs +};