diff --git a/README/DOBLE_DURABILIDAD_MINIJUEGOS.md b/README/DOBLE_DURABILIDAD_MINIJUEGOS.md new file mode 100644 index 0000000..9393dda --- /dev/null +++ b/README/DOBLE_DURABILIDAD_MINIJUEGOS.md @@ -0,0 +1,220 @@ +# 🔧⚔️ Doble Degradación de Herramientas en Minijuegos + +**Contexto**: Antes existía confusión: al minar, se mostraba que sólo se usaba la espada, cuando el jugador esperaba ver reflejado el pico usado + la espada degradándose por defenderse. + +--- + +## ✅ Cambios Implementados + +### 1. Separación de Herramientas (tool vs weaponTool) +- **`tool`**: Herramienta requerida para la actividad (pico para minar, caña para pescar, espada para pelear). +- **`weaponTool`**: Arma equipada que se degrada en **combate** (si hubo mobs y el jugador tenía espada equipada). + +**Beneficio**: Ahora minar usa el pico para recolectar minerales **y** la espada equipada para defenderse de mobs, cada una con su propia degradación. + +--- + +### 2. Balanceo de Durabilidad en Combate (50%) + +**Problema original**: Las armas caras se rompían al instante tras combate (desgaste completo configurado en `durabilityPerUse`). + +**Solución**: +- `reduceToolDurability()` ahora acepta parámetro `usage`: + - `"gather"` (default): desgaste completo (actividades de recolección/minería). + - `"combat"`: desgaste **reducido al 50%** (arma usada en combate). + +**Implementación**: +```typescript +async function reduceToolDurability( + userId: string, + guildId: string, + toolKey: string, + usage: "gather" | "combat" = "gather" +) { + let perUse = Math.max(1, breakable.durabilityPerUse ?? 1); + if (usage === "combat") { + perUse = Math.max(1, Math.ceil(perUse * 0.5)); // Reduce a la mitad, mínimo 1 + } + // ... resto de lógica +} +``` + +**Resultado**: Armas ahora duran el doble en combate, mejorando economía sin eliminar costo operativo. + +--- + +### 3. Extensión del Tipo `RunResult` + +Añadido campo opcional `weaponTool` al resultado de minijuegos: + +```typescript +export type RunResult = { + // ... campos existentes (tool, combat, rewards, etc.) + weaponTool?: { + key?: string; + durabilityDelta?: number; + broken?: boolean; + remaining?: number; + max?: number; + brokenInstance?: boolean; + instancesRemaining?: number; + toolSource?: "equipped"; + }; +}; +``` + +--- + +### 4. Lógica de Degradación en `runMinigame` + +Tras ejecutar combate, si hay mobs y el jugador tiene arma equipada: +1. Obtener `weapon` del slot equipment. +2. Validar que sea tipo `sword` y **no sea la misma herramienta principal** (evitar doble degradación en pelear). +3. Degradarla con `usage: "combat"`. +4. Adjuntar info a `weaponTool` en el resultado. + +**Código clave** (en `service.ts`): +```typescript +if (combatSummary && combatSummary.mobs.length > 0) { + const { weapon } = await getEquipment(userId, guildId); + if (weapon && weaponProps?.tool?.type === "sword") { + const alreadyMain = toolInfo?.key === weapon.key; + if (!alreadyMain) { + const wt = await reduceToolDurability(userId, guildId, weapon.key, "combat"); + weaponToolInfo = { ...wt, toolSource: "equipped" }; + } + } +} +``` + +--- + +### 5. Actualización de Comandos (UX) + +**Antes**: +``` +Herramienta: Espada de Hierro (50/150) [⚔️ Equipado] +``` +(El usuario pensaba que se estaba usando solo la espada para minar.) + +**Ahora** (comando `!mina` o `!pescar`): +``` +Pico: Pico Básico (95/100) [🔧 Auto] +Arma (defensa): Espada de Hierro (50/150) [⚔️ Equipado] +``` + +**Comando `!pelear`** (sin cambio visual, pues la espada es la herramienta principal): +``` +Arma: Espada de Hierro (50/150) [⚔️ Equipado] +``` + +**Implementación**: +- En `mina.ts`, `pescar.ts`, `pelear.ts` ahora se lee `result.weaponTool` adicional. +- Se construye `weaponInfo` con `formatToolLabel` y se incluye en el bloque de visualización. + +--- + +### 6. Ataques Programados (ScheduledMobAttack) + +Actualizado `attacksWorker.ts` para degradar arma equipada con `usage: "combat"` al recibir ataque de mobs. + +**Cambio**: +```typescript +await reduceToolDurability(job.userId, job.guildId, full.key, "combat"); +``` + +Asegura que ataques programados en background también respeten el balance del 50%. + +--- + +## 🎯 Resultados + +1. **Claridad**: Jugadores ven explícitamente qué herramienta se usó para recolectar y cuál para combate. +2. **Balance económico**: Armas duran el doble en combate, reduciendo costo operativo sin eliminar totalmente el desgaste. +3. **Consistencia**: El mismo sistema de doble degradación aplica para ataques programados, minijuegos activos y combate. + +--- + +## 📊 Ejemplos de Uso + +### Minar con Pico y Espada Equipada +``` +!mina 2 + +Área: mine.cavern • Nivel: 2 +Pico: Pico Básico (90/100) [-5 usos] [🔧 Auto] +Arma (defensa): Espada de Hierro (149/150) [-1 uso] [⚔️ Equipado] + +Recompensas: +• 🪙 +50 +• Mineral de Hierro x3 + +Mobs: +• slime +• goblin + +Combate: ⚔️ 2 mobs → 2 derrotados | 💀 Daño infligido: 45 | 🩹 Daño recibido: 8 +HP: ❤️❤️❤️❤️🤍 (85/100) +``` + +### Pescar con Caña y Arma +``` +!pescar 1 + +Área: lagoon.shore • Nivel: 1 +Caña: Caña Básica (77/80) [-3 usos] [🎣 Auto] +Arma (defensa): Espada de Hierro (148/150) [-1 uso] [⚔️ Equipado] + +Recompensas: +• Pez Común x2 +• 🪙 +10 + +Mobs: — +``` + +### Pelear (Espada como Tool Principal) +``` +!pelear 1 + +Área: fight.arena • Nivel: 1 +Arma: Espada de Hierro (148/150) [-2 usos] [⚔️ Equipado] + +Recompensas: +• 🪙 +25 + +Enemigos: +• slime + +Combate: ⚔️ 1 mob → 1 derrotado | 💀 Daño infligido: 18 | 🩹 Daño recibido: 3 +Victoria ✅ +HP: ❤️❤️❤️❤️❤️ (97/100) +``` + +--- + +## ⚙️ Configuración Recomendada + +Para ajustar desgaste según dificultad de tu servidor: + +1. **Herramientas de recolección** (picos, cañas): + - `durabilityPerUse`: 3-5 (se aplica completo en gather). + +2. **Armas** (espadas): + - `durabilityPerUse`: 2-4 (se reduce a 1-2 en combate por factor 0.5). + +3. **Eventos extremos**: + - Puedes crear ítems especiales con `durabilityPerUse: 1` para mayor longevidad o eventos sin desgaste (`enabled: false`). + +--- + +## 🔮 Próximos Pasos + +- [ ] Extender sistema a herramientas agrícolas (`hoe`, `watering_can`) con `usage: "farming"` y factor ajustable. +- [ ] Añadir mutaciones de ítems que reduzcan `durabilityPerUse` (ej: encantamiento "Durabilidad+" reduce desgaste en 25%). +- [ ] Implementar `ToolBreakLog` (migración propuesta en `PROPUESTA_MIGRACIONES_RPG.md`) para auditoría completa. + +--- + +**Fecha**: Octubre 2025 +**Autor**: Sistema RPG Integrado v2 +**Archivo**: `README/DOBLE_DURABILIDAD_MINIJUEGOS.md` diff --git a/package.json b/package.json index ae9ad03..37a4f9f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "start:prod-optimized": "NODE_ENV=production ENABLE_MEMORY_OPTIMIZER=true NODE_OPTIONS='--max-old-space-size=512 --expose-gc' npx tsx src/main.ts", "tsc": "tsc", "typecheck": "tsc --noEmit", - "seed:minigames": "tsx src/game/minigames/seed.ts", + "seed:minigames": "npx tsx src/game/minigames/seed.ts", "start:optimize-relic": "NODE_ENV=production ENABLE_MEMORY_OPTIMIZER=true NEW_RELIC_APP_NAME=amayo NEW_RELIC_LICENSE_KEY=85ef833e676ed6ea726e23b3e373397dFFFFNRAL NODE_OPTIONS='--max-old-space-size=512 --expose-gc' npx tsx --experimental-loader=newrelic/esm-loader.mjs src/main.ts" }, "keywords": [], diff --git a/src/commands/messages/game/mina.ts b/src/commands/messages/game/mina.ts index 4f347ab..ca8b710 100644 --- a/src/commands/messages/game/mina.ts +++ b/src/commands/messages/game/mina.ts @@ -76,6 +76,7 @@ export const command: CommandMessage = { ) .map((r) => r.itemKey!); if (result.tool?.key) rewardKeys.push(result.tool.key); + if (result.weaponTool?.key) rewardKeys.push(result.weaponTool.key); const rewardItems = await fetchItemBasics(guildId, rewardKeys); // Actualizar stats @@ -141,6 +142,27 @@ export const command: CommandMessage = { }) : "—"; + const weaponInfo = result.weaponTool?.key + ? formatToolLabel({ + key: result.weaponTool.key, + displayName: formatItemLabel( + rewardItems.get(result.weaponTool.key) ?? { + key: result.weaponTool.key, + name: null, + icon: null, + }, + { fallbackIcon: "⚔️" } + ), + instancesRemaining: result.weaponTool.instancesRemaining, + broken: result.weaponTool.broken, + brokenInstance: result.weaponTool.brokenInstance, + durabilityDelta: result.weaponTool.durabilityDelta, + remaining: result.weaponTool.remaining, + max: result.weaponTool.max, + source: result.weaponTool.toolSource, + }) + : null; + const combatSummary = result.combat ? combatSummaryRPG({ mobs: result.mobs.length, @@ -165,9 +187,12 @@ export const command: CommandMessage = { source === "global" ? "🌐 Configuración global" : "📍 Configuración local"; + const toolsLine = weaponInfo + ? `**Pico:** ${toolInfo}\n**Arma (defensa):** ${weaponInfo}` + : `**Herramienta:** ${toolInfo}`; blocks.push( textBlock( - `**Área:** \`${area.key}\` • ${areaScope}\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}` + `**Área:** \`${area.key}\` • ${areaScope}\n**Nivel:** ${level}\n${toolsLine}` ) ); blocks.push(dividerBlock({ divider: false, spacing: 1 })); diff --git a/src/commands/messages/game/pelear.ts b/src/commands/messages/game/pelear.ts index abc53c3..4d4aa15 100644 --- a/src/commands/messages/game/pelear.ts +++ b/src/commands/messages/game/pelear.ts @@ -77,6 +77,7 @@ export const command: CommandMessage = { ) .map((r) => r.itemKey!); if (result.tool?.key) rewardKeys.push(result.tool.key); + if (result.weaponTool?.key) rewardKeys.push(result.weaponTool.key); const rewardItems = await fetchItemBasics(guildId, rewardKeys); // Actualizar stats y misiones diff --git a/src/commands/messages/game/pescar.ts b/src/commands/messages/game/pescar.ts index b454cdf..74d4cfe 100644 --- a/src/commands/messages/game/pescar.ts +++ b/src/commands/messages/game/pescar.ts @@ -76,6 +76,7 @@ export const command: CommandMessage = { ) .map((r) => r.itemKey!); if (result.tool?.key) rewardKeys.push(result.tool.key); + if (result.weaponTool?.key) rewardKeys.push(result.weaponTool.key); const rewardItems = await fetchItemBasics(guildId, rewardKeys); // Actualizar stats y misiones @@ -135,6 +136,27 @@ export const command: CommandMessage = { source: result.tool.toolSource, }) : "—"; + + const weaponInfo = result.weaponTool?.key + ? formatToolLabel({ + key: result.weaponTool.key, + displayName: formatItemLabel( + rewardItems.get(result.weaponTool.key) ?? { + key: result.weaponTool.key, + name: null, + icon: null, + }, + { fallbackIcon: "⚔️" } + ), + instancesRemaining: result.weaponTool.instancesRemaining, + broken: result.weaponTool.broken, + brokenInstance: result.weaponTool.brokenInstance, + durabilityDelta: result.weaponTool.durabilityDelta, + remaining: result.weaponTool.remaining, + max: result.weaponTool.max, + source: result.weaponTool.toolSource, + }) + : null; const combatSummary = result.combat ? combatSummaryRPG({ mobs: result.mobs.length, @@ -159,9 +181,12 @@ export const command: CommandMessage = { source === "global" ? "🌐 Configuración global" : "📍 Configuración local"; + const toolsLine = weaponInfo + ? `**Caña:** ${toolInfo}\n**Arma (defensa):** ${weaponInfo}` + : `**Herramienta:** ${toolInfo}`; blocks.push( textBlock( - `**Área:** \`${area.key}\` • ${areaScope}\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}` + `**Área:** \`${area.key}\` • ${areaScope}\n**Nivel:** ${level}\n${toolsLine}` ) ); blocks.push(dividerBlock({ divider: false, spacing: 1 })); diff --git a/src/game/combat/attacksWorker.ts b/src/game/combat/attacksWorker.ts index 019df4c..49cb7c7 100644 --- a/src/game/combat/attacksWorker.ts +++ b/src/game/combat/attacksWorker.ts @@ -1,25 +1,35 @@ -import { prisma } from '../../core/database/prisma'; -import { ensurePlayerState, getEquipment, getEffectiveStats, adjustHP } from './equipmentService'; -import { reduceToolDurability } from '../minigames/service'; +import { prisma } from "../../core/database/prisma"; +import { + ensurePlayerState, + getEquipment, + getEffectiveStats, + adjustHP, +} from "./equipmentService"; +import { reduceToolDurability } from "../minigames/service"; -function getNumber(v: any, fallback = 0) { return typeof v === 'number' ? v : fallback; } +function getNumber(v: any, fallback = 0) { + return typeof v === "number" ? v : fallback; +} export async function processScheduledAttacks(limit = 25) { const now = new Date(); const jobs = await prisma.scheduledMobAttack.findMany({ - where: { status: 'scheduled', scheduleAt: { lte: now } }, - orderBy: { scheduleAt: 'asc' }, + where: { status: "scheduled", scheduleAt: { lte: now } }, + orderBy: { scheduleAt: "asc" }, take: limit, }); for (const job of jobs) { try { await prisma.$transaction(async (tx) => { // marcar processing - await tx.scheduledMobAttack.update({ where: { id: job.id }, data: { status: 'processing' } }); + await tx.scheduledMobAttack.update({ + where: { id: job.id }, + data: { status: "processing" }, + }); const mob = await tx.mob.findUnique({ where: { id: job.mobId } }); - if (!mob) throw new Error('Mob inexistente'); - const stats = mob.stats as any || {}; + if (!mob) throw new Error("Mob inexistente"); + const stats = (mob.stats as any) || {}; const mobAttack = Math.max(0, getNumber(stats.attack, 5)); await ensurePlayerState(job.userId, job.guildId); @@ -32,25 +42,49 @@ export async function processScheduledAttacks(limit = 25) { // desgastar arma equipada si existe const { eq, weapon } = await getEquipment(job.userId, job.guildId); if (weapon) { - // buscar por key para reducir durabilidad + // buscar por key para reducir durabilidad con multiplicador de combate (50%) // weapon tiene id; buscamos para traer key - const full = await tx.economyItem.findUnique({ where: { id: weapon.id } }); + const full = await tx.economyItem.findUnique({ + where: { id: weapon.id }, + }); if (full) { - await reduceToolDurability(job.userId, job.guildId, full.key); + await reduceToolDurability( + job.userId, + job.guildId, + full.key, + "combat" + ); } } // finalizar - await tx.scheduledMobAttack.update({ where: { id: job.id }, data: { status: 'done', processedAt: new Date() } }); + await tx.scheduledMobAttack.update({ + where: { id: job.id }, + data: { status: "done", processedAt: new Date() }, + }); }); } catch (e) { - await prisma.scheduledMobAttack.update({ where: { id: job.id }, data: { status: 'failed', processedAt: new Date(), metadata: { error: String(e) } as any } }); + await prisma.scheduledMobAttack.update({ + where: { id: job.id }, + data: { + status: "failed", + processedAt: new Date(), + metadata: { error: String(e) } as any, + }, + }); } } return { processed: jobs.length } as const; } if (require.main === module) { - processScheduledAttacks().then((r) => { console.log('[attacksWorker] processed', r.processed); process.exit(0); }).catch((e) => { console.error(e); process.exit(1); }); + processScheduledAttacks() + .then((r) => { + console.log("[attacksWorker] processed", r.processed); + process.exit(0); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); } - diff --git a/src/game/minigames/seed.ts b/src/game/minigames/seed.ts index fd087e6..2925148 100644 --- a/src/game/minigames/seed.ts +++ b/src/game/minigames/seed.ts @@ -1,134 +1,186 @@ -import { prisma } from '../../core/database/prisma'; -import { Prisma } from '@prisma/client'; +import { prisma } from "../../core/database/prisma"; +import { Prisma } from "@prisma/client"; -async function upsertEconomyItem(guildId: string | null, key: string, data: Omit) { +async function upsertEconomyItem( + guildId: string | null, + key: string, + data: Omit +) { if (guildId) { - return prisma.economyItem.upsert({ where: { guildId_key: { guildId, key } }, update: {}, create: { ...data, key, guildId } }); + return prisma.economyItem.upsert({ + where: { guildId_key: { guildId, key } }, + update: {}, + create: { ...data, key, guildId }, + }); } - const existing = await prisma.economyItem.findFirst({ where: { key, guildId: null } }); - if (existing) return prisma.economyItem.update({ where: { id: existing.id }, data: {} }); + const existing = await prisma.economyItem.findFirst({ + where: { key, guildId: null }, + }); + if (existing) + return prisma.economyItem.update({ where: { id: existing.id }, data: {} }); return prisma.economyItem.create({ data: { ...data, key, guildId: null } }); } -async function upsertGameArea(guildId: string | null, key: string, data: Omit) { +async function upsertGameArea( + guildId: string | null, + key: string, + data: Omit +) { if (guildId) { - return prisma.gameArea.upsert({ where: { guildId_key: { guildId, key } }, update: {}, create: { ...data, key, guildId } }); + return prisma.gameArea.upsert({ + where: { guildId_key: { guildId, key } }, + update: {}, + create: { ...data, key, guildId }, + }); } - const existing = await prisma.gameArea.findFirst({ where: { key, guildId: null } }); - if (existing) return prisma.gameArea.update({ where: { id: existing.id }, data: {} }); + const existing = await prisma.gameArea.findFirst({ + where: { key, guildId: null }, + }); + if (existing) + return prisma.gameArea.update({ where: { id: existing.id }, data: {} }); return prisma.gameArea.create({ data: { ...data, key, guildId: null } }); } -async function upsertMob(guildId: string | null, key: string, data: Omit) { +async function upsertMob( + guildId: string | null, + key: string, + data: Omit +) { if (guildId) { - return prisma.mob.upsert({ where: { guildId_key: { guildId, key } }, update: { stats: (data as any).stats, drops: (data as any).drops, name: (data as any).name }, create: { ...data, key, guildId } }); + return prisma.mob.upsert({ + where: { guildId_key: { guildId, key } }, + update: { + stats: (data as any).stats, + drops: (data as any).drops, + name: (data as any).name, + }, + create: { ...data, key, guildId }, + }); } - const existing = await prisma.mob.findFirst({ where: { key, guildId: null } }); - if (existing) return prisma.mob.update({ where: { id: existing.id }, data: { stats: (data as any).stats, drops: (data as any).drops, name: (data as any).name } }); + const existing = await prisma.mob.findFirst({ + where: { key, guildId: null }, + }); + if (existing) + return prisma.mob.update({ + where: { id: existing.id }, + data: { + stats: (data as any).stats, + drops: (data as any).drops, + name: (data as any).name, + }, + }); return prisma.mob.create({ data: { ...data, key, guildId: null } }); } async function main() { - const guildId = process.env.TEST_GUILD_ID ?? null; // null => global + const guildId = "1316592320954630144"; // null => global // Items base: herramientas y minerales - const pickKey = 'tool.pickaxe.basic'; - const rodKey = 'tool.rod.basic'; - const swordKey = 'weapon.sword.iron'; - const armorKey = 'armor.leather.basic'; - const capeKey = 'cape.life.minor'; + const pickKey = "tool.pickaxe.basic"; + const rodKey = "tool.rod.basic"; + const swordKey = "weapon.sword.iron"; + const armorKey = "armor.leather.basic"; + const capeKey = "cape.life.minor"; - const ironKey = 'ore.iron'; - const goldKey = 'ore.gold'; - const ironIngotKey = 'ingot.iron'; + const ironKey = "ore.iron"; + const goldKey = "ore.gold"; + const ironIngotKey = "ingot.iron"; - const fishCommonKey = 'fish.common'; - const fishRareKey = 'fish.rare'; + const fishCommonKey = "fish.common"; + const fishRareKey = "fish.rare"; // Herramientas await upsertEconomyItem(guildId, pickKey, { - name: 'Pico Básico', + name: "Pico Básico", stackable: false, props: { - tool: { type: 'pickaxe', tier: 1 }, + tool: { type: "pickaxe", tier: 1 }, breakable: { enabled: true, maxDurability: 100, durabilityPerUse: 5 }, } as unknown as Prisma.InputJsonValue, - tags: ['tool', 'mine'], + tags: ["tool", "mine"], }); await upsertEconomyItem(guildId, rodKey, { - name: 'Caña Básica', + name: "Caña Básica", stackable: false, props: { - tool: { type: 'rod', tier: 1 }, + tool: { type: "rod", tier: 1 }, breakable: { enabled: true, maxDurability: 80, durabilityPerUse: 3 }, } as unknown as Prisma.InputJsonValue, - tags: ['tool', 'fish'], + tags: ["tool", "fish"], }); // Arma, armadura y capa await upsertEconomyItem(guildId, swordKey, { - name: 'Espada de Hierro', + name: "Espada de Hierro", stackable: false, - props: { damage: 10, tool: { type: 'sword', tier: 1 }, breakable: { enabled: true, maxDurability: 150, durabilityPerUse: 2 } } as unknown as Prisma.InputJsonValue, - tags: ['weapon'], + props: { + damage: 10, + tool: { type: "sword", tier: 1 }, + breakable: { enabled: true, maxDurability: 150, durabilityPerUse: 2 }, + } as unknown as Prisma.InputJsonValue, + tags: ["weapon"], }); await upsertEconomyItem(guildId, armorKey, { - name: 'Armadura de Cuero', + name: "Armadura de Cuero", stackable: false, props: { defense: 3 } as unknown as Prisma.InputJsonValue, - tags: ['armor'], + tags: ["armor"], }); await upsertEconomyItem(guildId, capeKey, { - name: 'Capa de Vida Menor', + name: "Capa de Vida Menor", stackable: false, props: { maxHpBonus: 20 } as unknown as Prisma.InputJsonValue, - tags: ['cape'], + tags: ["cape"], }); // Materiales await upsertEconomyItem(guildId, ironKey, { - name: 'Mineral de Hierro', + name: "Mineral de Hierro", stackable: true, props: { craftingOnly: true } as unknown as Prisma.InputJsonValue, - tags: ['ore', 'common'], + tags: ["ore", "common"], }); await upsertEconomyItem(guildId, goldKey, { - name: 'Mineral de Oro', + name: "Mineral de Oro", stackable: true, props: { craftingOnly: true } as unknown as Prisma.InputJsonValue, - tags: ['ore', 'rare'], + tags: ["ore", "rare"], }); await upsertEconomyItem(guildId, ironIngotKey, { - name: 'Lingote de Hierro', + name: "Lingote de Hierro", stackable: true, props: {} as unknown as Prisma.InputJsonValue, - tags: ['ingot', 'metal'], + tags: ["ingot", "metal"], }); // Comida (pesca) que cura con cooldown await upsertEconomyItem(guildId, fishCommonKey, { - name: 'Pez Común', + name: "Pez Común", stackable: true, - props: { food: { healHp: 10, cooldownSeconds: 30 } } as unknown as Prisma.InputJsonValue, - tags: ['fish', 'food', 'common'], + props: { + food: { healHp: 10, cooldownSeconds: 30 }, + } as unknown as Prisma.InputJsonValue, + tags: ["fish", "food", "common"], }); await upsertEconomyItem(guildId, fishRareKey, { - name: 'Pez Raro', + name: "Pez Raro", stackable: true, - props: { food: { healHp: 20, healPercent: 5, cooldownSeconds: 45 } } as unknown as Prisma.InputJsonValue, - tags: ['fish', 'food', 'rare'], + props: { + food: { healHp: 20, healPercent: 5, cooldownSeconds: 45 }, + } as unknown as Prisma.InputJsonValue, + tags: ["fish", "food", "rare"], }); // Área de mina con niveles - const mineArea = await upsertGameArea(guildId, 'mine.cavern', { - name: 'Mina: Caverna', - type: 'MINE', + const mineArea = await upsertGameArea(guildId, "mine.cavern", { + name: "Mina: Caverna", + type: "MINE", config: { cooldownSeconds: 10 } as unknown as Prisma.InputJsonValue, }); @@ -138,9 +190,24 @@ async function main() { create: { areaId: mineArea.id, level: 1, - requirements: { tool: { required: true, toolType: 'pickaxe', minTier: 1 } } as unknown as Prisma.InputJsonValue, - rewards: { draws: 2, table: [ { type: 'item', itemKey: ironKey, qty: 2, weight: 70 }, { type: 'item', itemKey: ironKey, qty: 3, weight: 20 }, { type: 'item', itemKey: goldKey, qty: 1, weight: 10 } ] } as unknown as Prisma.InputJsonValue, - mobs: { draws: 1, table: [ { mobKey: 'bat', weight: 20 }, { mobKey: 'slime', weight: 10 } ] } as unknown as Prisma.InputJsonValue, + requirements: { + tool: { required: true, toolType: "pickaxe", minTier: 1 }, + } as unknown as Prisma.InputJsonValue, + rewards: { + draws: 2, + table: [ + { type: "item", itemKey: ironKey, qty: 2, weight: 70 }, + { type: "item", itemKey: ironKey, qty: 3, weight: 20 }, + { type: "item", itemKey: goldKey, qty: 1, weight: 10 }, + ], + } as unknown as Prisma.InputJsonValue, + mobs: { + draws: 1, + table: [ + { mobKey: "bat", weight: 20 }, + { mobKey: "slime", weight: 10 }, + ], + } as unknown as Prisma.InputJsonValue, }, }); @@ -150,16 +217,32 @@ async function main() { create: { areaId: mineArea.id, level: 2, - requirements: { tool: { required: true, toolType: 'pickaxe', minTier: 2 } } as unknown as Prisma.InputJsonValue, - rewards: { draws: 3, table: [ { type: 'item', itemKey: ironKey, qty: 3, weight: 60 }, { type: 'item', itemKey: goldKey, qty: 1, weight: 30 }, { type: 'coins', amount: 50, weight: 10 } ] } as unknown as Prisma.InputJsonValue, - mobs: { draws: 2, table: [ { mobKey: 'bat', weight: 20 }, { mobKey: 'slime', weight: 20 }, { mobKey: 'goblin', weight: 10 } ] } as unknown as Prisma.InputJsonValue, + requirements: { + tool: { required: true, toolType: "pickaxe", minTier: 2 }, + } as unknown as Prisma.InputJsonValue, + rewards: { + draws: 3, + table: [ + { type: "item", itemKey: ironKey, qty: 3, weight: 60 }, + { type: "item", itemKey: goldKey, qty: 1, weight: 30 }, + { type: "coins", amount: 50, weight: 10 }, + ], + } as unknown as Prisma.InputJsonValue, + mobs: { + draws: 2, + table: [ + { mobKey: "bat", weight: 20 }, + { mobKey: "slime", weight: 20 }, + { mobKey: "goblin", weight: 10 }, + ], + } as unknown as Prisma.InputJsonValue, }, }); // Área de laguna (pesca) - const lagoon = await upsertGameArea(guildId, 'lagoon.shore', { - name: 'Laguna: Orilla', - type: 'LAGOON', + const lagoon = await upsertGameArea(guildId, "lagoon.shore", { + name: "Laguna: Orilla", + type: "LAGOON", config: { cooldownSeconds: 12 } as unknown as Prisma.InputJsonValue, }); @@ -169,16 +252,25 @@ async function main() { create: { areaId: lagoon.id, level: 1, - requirements: { tool: { required: true, toolType: 'rod', minTier: 1 } } as unknown as Prisma.InputJsonValue, - rewards: { draws: 2, table: [ { type: 'item', itemKey: fishCommonKey, qty: 1, weight: 70 }, { type: 'item', itemKey: fishRareKey, qty: 1, weight: 10 }, { type: 'coins', amount: 10, weight: 20 } ] } as unknown as Prisma.InputJsonValue, + requirements: { + tool: { required: true, toolType: "rod", minTier: 1 }, + } as unknown as Prisma.InputJsonValue, + rewards: { + draws: 2, + table: [ + { type: "item", itemKey: fishCommonKey, qty: 1, weight: 70 }, + { type: "item", itemKey: fishRareKey, qty: 1, weight: 10 }, + { type: "coins", amount: 10, weight: 20 }, + ], + } as unknown as Prisma.InputJsonValue, mobs: { draws: 0, table: [] } as unknown as Prisma.InputJsonValue, }, }); // Área de pelea (arena) - const arena = await upsertGameArea(guildId, 'fight.arena', { - name: 'Arena de Combate', - type: 'FIGHT', + const arena = await upsertGameArea(guildId, "fight.arena", { + name: "Arena de Combate", + type: "FIGHT", config: { cooldownSeconds: 15 } as unknown as Prisma.InputJsonValue, }); @@ -188,36 +280,395 @@ async function main() { create: { areaId: arena.id, level: 1, - requirements: { tool: { required: true, toolType: 'sword', minTier: 1, allowedKeys: [swordKey] } } as unknown as Prisma.InputJsonValue, - rewards: { draws: 1, table: [ { type: 'coins', amount: 25, weight: 100 } ] } as unknown as Prisma.InputJsonValue, - mobs: { draws: 1, table: [ { mobKey: 'slime', weight: 50 }, { mobKey: 'goblin', weight: 50 } ] } as unknown as Prisma.InputJsonValue, + requirements: { + tool: { + required: true, + toolType: "sword", + minTier: 1, + allowedKeys: [swordKey], + }, + } as unknown as Prisma.InputJsonValue, + rewards: { + draws: 1, + table: [{ type: "coins", amount: 25, weight: 100 }], + } as unknown as Prisma.InputJsonValue, + mobs: { + draws: 1, + table: [ + { mobKey: "slime", weight: 50 }, + { mobKey: "goblin", weight: 50 }, + ], + } as unknown as Prisma.InputJsonValue, }, }); // Mobs básicos const mobs = [ - { key: 'bat', name: 'Murciélago', stats: { attack: 4 } }, - { key: 'slime', name: 'Slime', stats: { attack: 6 } }, - { key: 'goblin', name: 'Duende', stats: { attack: 8 } }, + { key: "bat", name: "Murciélago", stats: { attack: 4 } }, + { key: "slime", name: "Slime", stats: { attack: 6 } }, + { key: "goblin", name: "Duende", stats: { attack: 8 } }, ]; for (const m of mobs) { - await upsertMob(guildId, m.key, { name: m.name, stats: m.stats as unknown as Prisma.InputJsonValue, drops: Prisma.DbNull }); + await upsertMob(guildId, m.key, { + name: m.name, + stats: m.stats as unknown as Prisma.InputJsonValue, + drops: Prisma.DbNull, + }); } // Programar un par de ataques de mobs (demostración) - const targetUser = process.env.TEST_USER_ID; + const targetUser = "327207082203938818"; if (targetUser) { - const slime = await prisma.mob.findFirst({ where: { key: 'slime', OR: [{ guildId }, { guildId: null }] }, orderBy: [{ guildId: 'desc' }] }); + const slime = await prisma.mob.findFirst({ + where: { key: "slime", OR: [{ guildId }, { guildId: null }] }, + orderBy: [{ guildId: "desc" }], + }); if (slime) { const now = Date.now(); - await prisma.scheduledMobAttack.createMany({ data: [ - { userId: targetUser, guildId: (guildId ?? 'global'), mobId: slime.id, scheduleAt: new Date(now + 5_000) }, - { userId: targetUser, guildId: (guildId ?? 'global'), mobId: slime.id, scheduleAt: new Date(now + 15_000) }, - ] }); + await prisma.scheduledMobAttack.createMany({ + data: [ + { + userId: targetUser, + guildId: guildId ?? "global", + mobId: slime.id, + scheduleAt: new Date(now + 5_000), + }, + { + userId: targetUser, + guildId: guildId ?? "global", + mobId: slime.id, + scheduleAt: new Date(now + 15_000), + }, + ], + }); } } - console.log('[seed:minigames] done'); + // --------------------------------------------------------------------------- + // NUEVO CONTENIDO PARA PROBAR SISTEMAS AVANZADOS (tiers, riskFactor, fatiga) + // --------------------------------------------------------------------------- + + // Herramientas / equipo Tier 2 + const pickKeyT2 = "tool.pickaxe.iron"; + const rodKeyT2 = "tool.rod.oak"; + const swordKeyT2 = "weapon.sword.steel"; + const armorKeyT2 = "armor.chain.basic"; + const capeKeyT2 = "cape.life.moderate"; + + await upsertEconomyItem(guildId, pickKeyT2, { + name: "Pico de Hierro", + stackable: false, + props: { + tool: { type: "pickaxe", tier: 2 }, + breakable: { enabled: true, maxDurability: 180, durabilityPerUse: 4 }, + } as unknown as Prisma.InputJsonValue, + tags: ["tool", "mine", "tier2"], + }); + + await upsertEconomyItem(guildId, rodKeyT2, { + name: "Caña Robusta", + stackable: false, + props: { + tool: { type: "rod", tier: 2 }, + breakable: { enabled: true, maxDurability: 140, durabilityPerUse: 3 }, + } as unknown as Prisma.InputJsonValue, + tags: ["tool", "fish", "tier2"], + }); + + await upsertEconomyItem(guildId, swordKeyT2, { + name: "Espada de Acero", + stackable: false, + props: { + damage: 18, + tool: { type: "sword", tier: 2 }, + breakable: { enabled: true, maxDurability: 220, durabilityPerUse: 2 }, + } as unknown as Prisma.InputJsonValue, + tags: ["weapon", "tier2"], + }); + + await upsertEconomyItem(guildId, armorKeyT2, { + name: "Armadura de Cota de Malla", + stackable: false, + props: { defense: 6 } as unknown as Prisma.InputJsonValue, + tags: ["armor", "tier2"], + }); + + await upsertEconomyItem(guildId, capeKeyT2, { + name: "Capa de Vida Moderada", + stackable: false, + props: { maxHpBonus: 40 } as unknown as Prisma.InputJsonValue, + tags: ["cape", "tier2"], + }); + + // Consumibles / pruebas de curación y limpieza de efectos + const bigFoodKey = "food.meat.large"; + const fatigueClearPotionKey = "potion.fatigue.clear"; + + await upsertEconomyItem(guildId, bigFoodKey, { + name: "Carne Asada Grande", + stackable: true, + props: { + food: { healHp: 40, healPercent: 10, cooldownSeconds: 60 }, + } as unknown as Prisma.InputJsonValue, + tags: ["food", "healing"], + }); + + await upsertEconomyItem(guildId, fatigueClearPotionKey, { + name: "Poción Energética", + stackable: true, + props: { + potion: { removeEffects: ["FATIGUE"], cooldownSeconds: 90 }, + } as unknown as Prisma.InputJsonValue, + tags: ["potion", "utility"], + }); + + // ÁREA NUEVA: Mina de Fisura (más riesgo => probar penalización muerte) + const riftMine = await upsertGameArea(guildId, "mine.rift", { + name: "Mina: Fisura Cristalina", + type: "MINE", + config: { cooldownSeconds: 14 } as unknown as Prisma.InputJsonValue, + metadata: { riskFactor: 1.6 } as unknown as Prisma.InputJsonValue, + }); + + await prisma.gameAreaLevel.upsert({ + where: { areaId_level: { areaId: riftMine.id, level: 1 } }, + update: {}, + create: { + areaId: riftMine.id, + level: 1, + requirements: { + tool: { required: true, toolType: "pickaxe", minTier: 2 }, + } as unknown as Prisma.InputJsonValue, + rewards: { + draws: 3, + table: [ + { type: "item", itemKey: ironKey, qty: 4, weight: 55 }, + { type: "item", itemKey: goldKey, qty: 2, weight: 20 }, + { type: "coins", amount: 60, weight: 25 }, + ], + } as unknown as Prisma.InputJsonValue, + mobs: { + draws: 2, + table: [ + { mobKey: "goblin", weight: 25 }, + { mobKey: "orc", weight: 15 }, + ], + } as unknown as Prisma.InputJsonValue, + metadata: { suggestedHp: 120 } as unknown as Prisma.InputJsonValue, + }, + }); + + // Extensión de la mina existente: nivel 3 + await prisma.gameAreaLevel.upsert({ + where: { areaId_level: { areaId: mineArea.id, level: 3 } }, + update: {}, + create: { + areaId: mineArea.id, + level: 3, + requirements: { + tool: { required: true, toolType: "pickaxe", minTier: 2 }, + } as unknown as Prisma.InputJsonValue, + rewards: { + draws: 4, + table: [ + { type: "item", itemKey: ironKey, qty: 4, weight: 50 }, + { type: "item", itemKey: goldKey, qty: 2, weight: 25 }, + { type: "coins", amount: 80, weight: 15 }, + { type: "coins", amount: 120, weight: 10 }, + ], + } as unknown as Prisma.InputJsonValue, + mobs: { + draws: 2, + table: [ + { mobKey: "slime", weight: 20 }, + { mobKey: "goblin", weight: 20 }, + { mobKey: "orc", weight: 10 }, + ], + } as unknown as Prisma.InputJsonValue, + }, + }); + + // Laguna nivel 2 + await prisma.gameAreaLevel.upsert({ + where: { areaId_level: { areaId: lagoon.id, level: 2 } }, + update: {}, + create: { + areaId: lagoon.id, + level: 2, + requirements: { + tool: { required: true, toolType: "rod", minTier: 2 }, + } as unknown as Prisma.InputJsonValue, + rewards: { + draws: 3, + table: [ + { type: "item", itemKey: fishCommonKey, qty: 2, weight: 60 }, + { type: "item", itemKey: fishRareKey, qty: 1, weight: 20 }, + { type: "coins", amount: 30, weight: 20 }, + ], + } as unknown as Prisma.InputJsonValue, + mobs: { draws: 0, table: [] } as unknown as Prisma.InputJsonValue, + }, + }); + + // Arena existente: nivel 2 + await prisma.gameAreaLevel.upsert({ + where: { areaId_level: { areaId: arena.id, level: 2 } }, + update: {}, + create: { + areaId: arena.id, + level: 2, + requirements: { + tool: { required: true, toolType: "sword", minTier: 2 }, + } as unknown as Prisma.InputJsonValue, + rewards: { + draws: 1, + table: [ + { type: "coins", amount: 60, weight: 70 }, + { type: "coins", amount: 90, weight: 30 }, + ], + } as unknown as Prisma.InputJsonValue, + mobs: { + draws: 1, + table: [ + { mobKey: "goblin", weight: 40 }, + { mobKey: "orc", weight: 40 }, + { mobKey: "troll", weight: 20 }, + ], + } as unknown as Prisma.InputJsonValue, + }, + }); + + // Arena élite separada para probar riskFactor de muerte + const eliteArena = await upsertGameArea(guildId, "fight.arena.elite", { + name: "Arena de Combate Élite", + type: "FIGHT", + config: { cooldownSeconds: 25 } as unknown as Prisma.InputJsonValue, + metadata: { riskFactor: 1.4 } as unknown as Prisma.InputJsonValue, + }); + + await prisma.gameAreaLevel.upsert({ + where: { areaId_level: { areaId: eliteArena.id, level: 1 } }, + update: {}, + create: { + areaId: eliteArena.id, + level: 1, + requirements: { + tool: { required: true, toolType: "sword", minTier: 2 }, + } as unknown as Prisma.InputJsonValue, + rewards: { + draws: 1, + table: [ + { type: "coins", amount: 120, weight: 70 }, + { type: "coins", amount: 180, weight: 30 }, + ], + } as unknown as Prisma.InputJsonValue, + mobs: { + draws: 1, + table: [ + { mobKey: "orc", weight: 40 }, + { mobKey: "troll", weight: 35 }, + { mobKey: "dragonling", weight: 25 }, + ], + } as unknown as Prisma.InputJsonValue, + metadata: { suggestedHp: 150 } as unknown as Prisma.InputJsonValue, + }, + }); + + // Nuevos mobs avanzados + const extraMobs = [ + { key: "orc", name: "Orco", stats: { attack: 12, defense: 2 } }, + { key: "troll", name: "Trol", stats: { attack: 20, defense: 4 } }, + { + key: "dragonling", + name: "Dragoncito", + stats: { attack: 35, defense: 6 }, + }, + ]; + for (const m of extraMobs) { + await upsertMob(guildId, m.key, { + name: m.name, + stats: m.stats as unknown as Prisma.InputJsonValue, + drops: Prisma.DbNull, + }); + } + + // Programar ataques extra de mobs nuevos para pruebas (si existe user objetivo) + if (targetUser) { + const orc = await prisma.mob.findFirst({ + where: { key: "orc", OR: [{ guildId }, { guildId: null }] }, + orderBy: [{ guildId: "desc" }], + }); + const dragon = await prisma.mob.findFirst({ + where: { key: "dragonling", OR: [{ guildId }, { guildId: null }] }, + orderBy: [{ guildId: "desc" }], + }); + const now = Date.now(); + const extraAttacks: Prisma.ScheduledMobAttackCreateManyInput[] = []; + if (orc) { + extraAttacks.push({ + userId: targetUser, + guildId: guildId ?? "global", + mobId: orc.id, + scheduleAt: new Date(now + 25_000), + }); + } + if (dragon) { + extraAttacks.push({ + userId: targetUser, + guildId: guildId ?? "global", + mobId: dragon.id, + scheduleAt: new Date(now + 40_000), + }); + } + if (extraAttacks.length) { + await prisma.scheduledMobAttack.createMany({ data: extraAttacks }); + } + } + + // Insertar un efecto FATIGUE de prueba (15% por 30 min) para validar penalización de monedas y reducción de stats + if (targetUser) { + const expires = new Date(Date.now() + 30 * 60 * 1000); + await prisma.playerStatusEffect.upsert({ + where: { + userId_guildId_type: { + userId: targetUser, + guildId: guildId ?? "global", + type: "FATIGUE", + }, + }, + update: { magnitude: 0.15, expiresAt: expires }, + create: { + userId: targetUser, + guildId: guildId ?? "global", + type: "FATIGUE", + magnitude: 0.15, + expiresAt: expires, + }, + }); + } + + // Asegurar PlayerState base para el usuario de prueba + if (targetUser) { + await prisma.playerState.upsert({ + where: { + userId_guildId: { userId: targetUser, guildId: guildId ?? "global" }, + }, + update: {}, + create: { + userId: targetUser, + guildId: guildId ?? "global", + hp: 100, + maxHp: 100, + }, + }); + } + + console.log("[seed:minigames] done"); } -main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); }); +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/src/game/minigames/service.ts b/src/game/minigames/service.ts index 7e4eb62..7a37ba8 100644 --- a/src/game/minigames/service.ts +++ b/src/game/minigames/service.ts @@ -14,6 +14,7 @@ import { getEffectiveStats, adjustHP, ensurePlayerState, + getEquipment, } from "../combat/equipmentService"; // 🟩 local authoritative import { logToolBreak } from "../lib/toolBreakLog"; import { updateStats } from "../stats/service"; // 🟩 local authoritative @@ -269,7 +270,8 @@ async function sampleMobs(mobs?: MobsTable): Promise { async function reduceToolDurability( userId: string, guildId: string, - toolKey: string + toolKey: string, + usage: "gather" | "combat" = "gather" ) { const { item, entry } = await getInventoryEntry(userId, guildId, toolKey); if (!entry) @@ -298,6 +300,11 @@ async function reduceToolDurability( // Valores base const maxConfigured = Math.max(1, breakable.maxDurability ?? 1); let perUse = Math.max(1, breakable.durabilityPerUse ?? 1); + // Ajuste: en combate degradamos menos para evitar roturas instantáneas de armas caras + if (usage === "combat") { + // Reducimos a la mitad (redondeo hacia arriba mínimo 1) + perUse = Math.max(1, Math.ceil(perUse * 0.5)); + } // Protección: si perUse > maxDurability asumimos configuración errónea y lo reducimos a 1 // (en lugar de romper inmediatamente el ítem). Si quieres que se rompa de un uso, define maxDurability igual a 1. @@ -758,10 +765,49 @@ export async function runMinigame( } // Registrar la ejecución + let weaponToolInfo: RunResult["weaponTool"] | undefined; + // Si hubo combate y el jugador tenía un arma equipada distinta de la herramienta de recolección, degradarla. + if (combatSummary && combatSummary.mobs.length > 0) { + try { + const { weapon } = await getEquipment(userId, guildId); + if (weapon) { + const weaponProps = parseItemProps(weapon.props); + if (weaponProps?.tool?.type === "sword") { + // Evitar degradar dos veces si la herramienta principal ya era la espada usada para recoger (no aplica en mina/pesca normalmente) + const alreadyMain = toolInfo?.key === weapon.key; + if (!alreadyMain) { + const wt = await reduceToolDurability( + userId, + guildId, + weapon.key, + "combat" + ); + weaponToolInfo = { + key: weapon.key, + durabilityDelta: wt.delta, + broken: wt.broken, + remaining: wt.remaining, + max: wt.max, + brokenInstance: wt.brokenInstance, + instancesRemaining: wt.instancesRemaining, + toolSource: "equipped", + }; + } else { + // Si la espada era también la herramienta (pelear) ya se degradó en la fase de tool principal + weaponToolInfo = undefined; + } + } + } + } catch { + // silencioso + } + } + const resultJson: Prisma.InputJsonValue = { rewards: delivered, mobs: mobsSpawned, tool: toolInfo, + weaponTool: weaponToolInfo, combat: combatSummary, rewardModifiers, notes: "auto", @@ -810,6 +856,7 @@ export async function runMinigame( rewards: delivered, mobs: mobsSpawned, tool: toolInfo, + weaponTool: weaponToolInfo, combat: combatSummary, rewardModifiers, }; diff --git a/src/game/minigames/types.ts b/src/game/minigames/types.ts index 73cdce3..81adc6f 100644 --- a/src/game/minigames/types.ts +++ b/src/game/minigames/types.ts @@ -61,6 +61,17 @@ export type RunResult = { instancesRemaining?: number; // instancias que quedan después del uso toolSource?: "provided" | "equipped" | "auto"; // origen de la selección }; + // Nueva: arma usada en combate (se degrada con un multiplicador menor para evitar roturas instantáneas) + weaponTool?: { + key?: string; + durabilityDelta?: number; + broken?: boolean; + remaining?: number; + max?: number; + brokenInstance?: boolean; + instancesRemaining?: number; + toolSource?: "equipped"; // siempre proviene del slot de arma + }; combat?: CombatSummary; // resumen de combate si hubo mobs y se procesó // Modificadores aplicados a las recompensas (ej: penalización por FATIGUE sobre monedas) rewardModifiers?: {