From b10cc895838339007d320f61a6fc87cf2be798c4 Mon Sep 17 00:00:00 2001 From: shni Date: Sun, 5 Oct 2025 05:28:07 -0500 Subject: [PATCH] feat(economy): implement streak and stats commands with detailed user feedback --- SUGERENCIAS_Y_MEJORAS.md | 643 +++++++++++++++++++++++++++- src/commands/messages/game/racha.ts | 110 +++++ src/commands/messages/game/stats.ts | 77 ++++ src/game/achievements/service.ts | 244 +++++++++++ src/game/quests/service.ts | 300 +++++++++++++ src/game/rewards/service.ts | 95 ++++ src/game/stats/service.ts | 183 ++++++++ src/game/stats/types.ts | 41 ++ src/game/streaks/service.ts | 170 ++++++++ 9 files changed, 1862 insertions(+), 1 deletion(-) create mode 100644 src/commands/messages/game/racha.ts create mode 100644 src/commands/messages/game/stats.ts create mode 100644 src/game/achievements/service.ts create mode 100644 src/game/quests/service.ts create mode 100644 src/game/rewards/service.ts create mode 100644 src/game/stats/service.ts create mode 100644 src/game/stats/types.ts create mode 100644 src/game/streaks/service.ts diff --git a/SUGERENCIAS_Y_MEJORAS.md b/SUGERENCIAS_Y_MEJORAS.md index 5fd0658..a3ae6a2 100644 --- a/SUGERENCIAS_Y_MEJORAS.md +++ b/SUGERENCIAS_Y_MEJORAS.md @@ -614,4 +614,645 @@ Tu bot tiene una **base excelente y muy completa**. Las sugerencias principales 3. **Más visual:** DisplayComponents en todos lados 4. **Más feedback:** Stats, logs, notificaciones -Ahora voy a crear el **comando de tienda completo con DisplayComponents** 🚀 +--- + +## 🛠️ Comandos Sugeridos para Implementar + +### Comandos de Sistema de Logros +```typescript +// src/commands/messages/game/logros.ts +export const command: CommandMessage = { + name: 'logros', + aliases: ['achievements', 'logro'], + description: 'Ver tus logros y progreso', + usage: 'logros [@usuario]', + // ... implementación +}; + +// src/commands/messages/admin/logro-crear.ts +export const command: CommandMessage = { + name: 'logro-crear', + description: 'Crear un nuevo logro', + usage: 'logro-crear ', + // Usar editor interactivo similar a areaCreate.ts +}; +``` + +### Comandos de Misiones +```typescript +// src/commands/messages/game/misiones.ts +export const command: CommandMessage = { + name: 'misiones', + aliases: ['quests', 'mision'], + description: 'Ver misiones disponibles y tu progreso', + usage: 'misiones [categoria]', + // Usar DisplayComponents para mostrar misiones visuales +}; + +// src/commands/messages/game/mision-reclamar.ts +export const command: CommandMessage = { + name: 'mision-reclamar', + aliases: ['claim-quest'], + description: 'Reclamar recompensa de misión completada', + usage: 'mision-reclamar ', +}; +``` + +### Comandos de Estadísticas +```typescript +// src/commands/messages/game/stats.ts +export const command: CommandMessage = { + name: 'stats', + aliases: ['estadisticas', 'statistics'], + description: 'Ver estadísticas detalladas de un jugador', + usage: 'stats [@usuario]', + // Mostrar con DisplayComponents estilo tarjeta +}; + +// src/commands/messages/game/ranking-stats.ts +export const command: CommandMessage = { + name: 'ranking-stats', + aliases: ['top-stats'], + description: 'Ver ranking de jugadores por estadísticas', + usage: 'ranking-stats [categoria]', + // Categorías: minas, pesca, combate, monedas, etc. +}; +``` + +### Comandos de Tienda Mejorada +```typescript +// src/commands/messages/game/tienda.ts +export const command: CommandMessage = { + name: 'tienda', + aliases: ['shop', 'store'], + description: 'Ver la tienda del servidor con interfaz visual', + usage: 'tienda [categoria] [pagina]', + // Usar DisplayComponents con: + // - Categorías en botones + // - Paginación + // - Botones de compra rápida + // - Preview de items con stats +}; +``` + +### Comandos de Rachas +```typescript +// src/commands/messages/game/racha.ts +export const command: CommandMessage = { + name: 'racha', + aliases: ['streak', 'daily'], + description: 'Ver tu racha diaria y reclamar recompensa', + usage: 'racha', + // Auto-incrementa si jugaste hoy + // Muestra recompensa del día +}; +``` + +### Comandos de Cooldowns +```typescript +// src/commands/messages/game/cooldowns.ts +export const command: CommandMessage = { + name: 'cooldowns', + aliases: ['cds', 'tiempos'], + description: 'Ver todos tus cooldowns activos', + usage: 'cooldowns', + // Lista todos los cooldowns con tiempo restante +}; +``` + +--- + +## 🔧 Servicios a Crear + +### 1. Achievement Service +```typescript +// src/game/achievements/service.ts + +export async function checkAchievements( + userId: string, + guildId: string, + trigger: string +) { + // Verificar logros desbloqueables basados en el trigger + // Triggers: "mine", "fish", "combat", "craft", etc. + // Retornar array de logros nuevos desbloqueados +} + +export async function unlockAchievement( + userId: string, + guildId: string, + achievementKey: string +) { + // Desbloquear logro + // Dar recompensas + // Crear notificación +} + +export async function getAchievementProgress( + userId: string, + guildId: string, + achievementKey: string +) { + // Calcular progreso actual del logro + // Retornar porcentaje y valores +} +``` + +### 2. Quest Service +```typescript +// src/game/quests/service.ts + +export async function generateDailyQuests(guildId: string) { + // Generar misiones diarias aleatorias + // Llamar cada día a medianoche +} + +export async function updateQuestProgress( + userId: string, + guildId: string, + questType: string, + increment: number = 1 +) { + // Actualizar progreso de misiones activas + // Completar automáticamente si alcanza el objetivo +} + +export async function claimQuestReward( + userId: string, + guildId: string, + questId: string +) { + // Verificar que esté completada + // Dar recompensas + // Marcar como reclamada +} +``` + +### 3. Stats Service +```typescript +// src/game/stats/service.ts + +export async function updateStats( + userId: string, + guildId: string, + updates: Partial +) { + // Actualizar stats del jugador + // Verificar récords + // Llamar desde otros servicios +} + +export async function getLeaderboard( + guildId: string, + category: string, + limit: number = 10 +) { + // Obtener top jugadores en una categoría + // Categorías: totalMines, mobsDefeated, coinsEarned, etc. +} +``` + +### 4. Streak Service +```typescript +// src/game/streaks/service.ts + +export async function updateStreak( + userId: string, + guildId: string +) { + // Actualizar racha diaria + // Resetear si pasó más de 1 día + // Retornar recompensa del día +} + +export async function getStreakReward(day: number) { + // Calcular recompensa según el día de racha + // Escalar recompensas por día +} +``` + +--- + +## 📊 Mejoras al Código Existente + +### 1. Centralizar Manejo de Recompensas +```typescript +// src/game/rewards/service.ts + +export interface Reward { + coins?: number; + items?: Array<{ key: string; quantity: number }>; + xp?: number; + title?: string; +} + +export async function giveRewards( + userId: string, + guildId: string, + rewards: Reward, + source: string // "achievement", "quest", "streak", etc. +) { + const results = []; + + if (rewards.coins) { + await adjustCoins(userId, guildId, rewards.coins); + results.push(`💰 ${rewards.coins} monedas`); + } + + if (rewards.items) { + for (const item of rewards.items) { + await addItemByKey(userId, guildId, item.key, item.quantity); + results.push(`📦 ${item.quantity}x ${item.key}`); + } + } + + // Log para auditoría + await prisma.auditLog.create({ + data: { + userId, + guildId, + action: 'reward_given', + target: source, + details: rewards + } + }); + + return results; +} +``` + +### 2. Mejorar Sistema de Notificaciones +```typescript +// src/core/lib/notifications.ts + +export async function notifyAchievement( + user: User, + guild: Guild, + achievement: Achievement +) { + // Enviar DM al usuario + // O mensaje en canal específico + // Con DisplayComponents mostrando el logro +} + +export async function notifyQuestComplete( + user: User, + guild: Guild, + quest: Quest +) { + // Notificar que puede reclamar recompensa +} +``` + +### 3. Helpers para DisplayComponents +```typescript +// src/core/lib/display-helpers.ts + +export function createItemCard(item: EconomyItem): DisplayComponent { + return { + type: 'container', + layout: 'vertical', + components: [ + { + type: 'image', + url: item.iconUrl || 'default-item.png', + alt: item.name + }, + { + type: 'text', + content: `**${item.name}**\n${item.description}`, + style: 'bold' + }, + { + type: 'text', + content: `💰 ${item.basePrice} monedas`, + style: 'muted' + } + ] + }; +} + +export function createProgressBar( + current: number, + total: number, + width: number = 10 +): string { + const filled = Math.floor((current / total) * width); + const empty = width - filled; + return '█'.repeat(filled) + '░'.repeat(empty) + ` ${current}/${total}`; +} + +export function createStatCard( + title: string, + stats: Record +): DisplayComponent { + return { + type: 'embed', + title: title, + fields: Object.entries(stats).map(([key, value]) => ({ + name: key, + value: value.toString(), + inline: true + })) + }; +} +``` + +--- + +## 🎮 Integración con Sistema Existente + +### Hooks en Comandos Existentes + +Añadir estas llamadas a los comandos que ya tienes: + +```typescript +// En src/commands/messages/game/mina.ts +import { updateStats } from '../../../game/stats/service'; +import { updateQuestProgress } from '../../../game/quests/service'; +import { checkAchievements } from '../../../game/achievements/service'; + +// Después de minar exitosamente: +await updateStats(userId, guildId, { minesCompleted: 1 }); +await updateQuestProgress(userId, guildId, 'mine', 1); +const newAchievements = await checkAchievements(userId, guildId, 'mine'); + +// Similar en pescar.ts, pelear.ts, craftear.ts, etc. +``` + +### Cron Jobs para Sistema de Misiones + +```typescript +// src/core/cron/daily-reset.ts +import cron from 'node-cron'; + +export function setupDailyReset() { + // Ejecutar cada día a medianoche (hora del servidor) + cron.schedule('0 0 * * *', async () => { + const guilds = await prisma.guild.findMany(); + + for (const guild of guilds) { + // Generar nuevas misiones diarias + await generateDailyQuests(guild.id); + + // Limpiar misiones expiradas + await cleanExpiredQuests(guild.id); + + // Actualizar rachas + // (se actualizan automáticamente al usar comandos) + } + }); +} +``` + +--- + +## 📈 Métricas y Analytics + +### Tracking de Uso +```typescript +// src/core/analytics/tracker.ts + +export async function trackCommand( + userId: string, + guildId: string, + commandName: string +) { + await prisma.auditLog.create({ + data: { + userId, + guildId, + action: 'command_used', + target: commandName, + details: { timestamp: new Date() } + } + }); +} + +export async function getCommandStats(guildId: string, days: number = 7) { + // Obtener comandos más usados + // Usuarios más activos + // Horas pico de uso +} +``` + +--- + +## 🚀 Plan de Implementación Sugerido + +### Fase 1: Engagement Básico (Sprint 1-2) +1. ✅ Implementar `PlayerStats` y servicio +2. ✅ Añadir tracking en comandos existentes +3. ✅ Crear comando `!stats` +4. ✅ Crear comando `!cooldowns` + +### Fase 2: Sistema de Logros (Sprint 3-4) +1. ✅ Implementar Achievement Service +2. ✅ Crear logros básicos (10-15) +3. ✅ Integrar checks en comandos +4. ✅ Crear comando `!logros` +5. ✅ Sistema de notificaciones + +### Fase 3: Misiones Diarias (Sprint 5-6) +1. ✅ Implementar Quest System +2. ✅ Crear misiones diarias/semanales +3. ✅ Cron job para reset diario +4. ✅ Comandos de misiones +5. ✅ UI con DisplayComponents + +### Fase 4: Rachas y Recompensas (Sprint 7) +1. ✅ Implementar Streak System +2. ✅ Comando `!racha` con recompensas +3. ✅ Integrar con sistema de recompensas + +### Fase 5: Tienda Mejorada (Sprint 8) +1. ✅ Refactorizar comando tienda +2. ✅ DisplayComponents avanzados +3. ✅ Categorías y filtros +4. ✅ Compra rápida con botones + +### Fase 6: Rankings y Social (Sprint 9-10) +1. ✅ Rankings por categorías +2. ✅ Leaderboards globales +3. ✅ Sistema de menciones/comparación + +--- + +## 🎨 Ejemplos Visuales de DisplayComponents + +### Logro Desbloqueado +```typescript +{ + type: 'embed', + color: 0xFFD700, // Dorado + title: '🏆 ¡Logro Desbloqueado!', + description: '**Primera Mina**\nHas minado por primera vez', + thumbnail: 'achievement-first-mine.png', + fields: [ + { + name: '💰 Recompensa', + value: '500 monedas', + inline: true + }, + { + name: '📊 Progreso', + value: '1/100 logros', + inline: true + } + ], + footer: 'Usa !logros para ver todos tus logros' +} +``` + +### Misión Completada +```typescript +{ + type: 'embed', + color: 0x00FF00, // Verde + title: '✅ Misión Completada!', + description: '**Minero Diario**\nMina 10 veces', + fields: [ + { + name: '📦 Recompensas', + value: '• 1000 monedas\n• 3x Pico de Hierro\n• 50 XP', + inline: false + } + ], + components: [ + { + type: 'button', + label: 'Reclamar', + style: 'success', + customId: 'claim_quest_123' + } + ] +} +``` + +### Stats del Jugador +```typescript +{ + type: 'embed', + color: 0x5865F2, // Discord Blurple + title: '📊 Estadísticas de @Usuario', + thumbnail: 'user-avatar.png', + fields: [ + { name: '⛏️ Minas', value: '234', inline: true }, + { name: '🎣 Pesca', value: '156', inline: true }, + { name: '⚔️ Combates', value: '89', inline: true }, + { name: '💰 Monedas Ganadas', value: '456,789', inline: true }, + { name: '🛠️ Items Crafteados', value: '67', inline: true }, + { name: '👾 Mobs Derrotados', value: '423', inline: true }, + { name: '🏆 Logros', value: '23/100', inline: true }, + { name: '🔥 Racha Actual', value: '12 días', inline: true }, + { name: '⭐ Nivel', value: '15', inline: true } + ], + footer: 'Jugando desde: 15 Ene 2024' +} +``` + +--- + +## 💡 Tips de Optimización + +### 1. Caché para Queries Frecuentes +```typescript +import { Cache } from 'node-cache'; + +const leaderboardCache = new Cache({ stdTTL: 300 }); // 5 minutos + +export async function getCachedLeaderboard(guildId: string, category: string) { + const cacheKey = `leaderboard:${guildId}:${category}`; + + let data = leaderboardCache.get(cacheKey); + if (data) return data; + + data = await getLeaderboard(guildId, category); + leaderboardCache.set(cacheKey, data); + + return data; +} +``` + +### 2. Batch Updates para Stats +```typescript +// En lugar de actualizar cada stat individualmente +// Acumular y hacer update único al final del comando + +const statsUpdates = { + minesCompleted: 1, + totalCoinsEarned: reward +}; + +await updateStats(userId, guildId, statsUpdates); +``` + +### 3. Índices en Prisma +Ya los tienes definidos, asegúrate de que estén aplicados: +```bash +npx prisma migrate dev +``` + +--- + +## 🎯 Checklist de Implementación + +### Antes de Empezar +- [ ] Backup de la base de datos +- [ ] Branch nueva en Git +- [ ] Revisar modelos de Prisma actuales +- [ ] Planificar migraciones + +### Durante Implementación +- [ ] Tests unitarios para servicios nuevos +- [ ] Documentar APIs internas +- [ ] Validación de inputs +- [ ] Manejo de errores +- [ ] Logs apropiados + +### Después de Implementar +- [ ] Pruebas en servidor de desarrollo +- [ ] Code review +- [ ] Actualizar documentación de usuario +- [ ] Deploy gradual +- [ ] Monitorear métricas + +--- + +## 📚 Recursos Adicionales + +### Documentación Útil +- [Discord.js Guide](https://discordjs.guide/) +- [Prisma Docs](https://www.prisma.io/docs) +- [Discord Interactions](https://discord.com/developers/docs/interactions/overview) + +### Herramientas Recomendadas +- **Bull/BullMQ**: Para cron jobs y queues +- **node-cache**: Para caché en memoria +- **zod**: Para validación de schemas +- **winston**: Para logging avanzado + +--- + +## ✨ Conclusión Final + +Tu proyecto **Amayo** es impresionante y tiene todos los componentes base necesarios. Con estas mejoras sugeridas, puedes convertirlo en un bot de economía y RPG de nivel profesional que mantenga a los usuarios enganchados por meses. + +Las prioridades recomendadas son: + +**Inmediato (Semana 1-2):** +- Sistema de Stats completo +- Comando cooldowns +- Mejorar comando tienda + +**Corto Plazo (Mes 1):** +- Sistema de logros básico +- Misiones diarias +- Sistema de rachas + +**Mediano Plazo (Mes 2-3):** +- Sistema de clanes +- Eventos temporales +- Trading entre jugadores + +¡Mucho éxito con el desarrollo! 🚀 diff --git a/src/commands/messages/game/racha.ts b/src/commands/messages/game/racha.ts new file mode 100644 index 0000000..e58e2aa --- /dev/null +++ b/src/commands/messages/game/racha.ts @@ -0,0 +1,110 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { getStreakInfo, updateStreak } from '../../../game/streaks/service'; +import { EmbedBuilder } from 'discord.js'; + +export const command: CommandMessage = { + name: 'racha', + type: 'message', + aliases: ['streak', 'daily'], + cooldown: 10, + description: 'Ver tu racha diaria y reclamar recompensa', + usage: 'racha', + run: async (message, args, client: Amayo) => { + try { + const userId = message.author.id; + const guildId = message.guild!.id; + + // Actualizar racha + const { streak, newDay, rewards, daysIncreased } = await updateStreak(userId, guildId); + + const embed = new EmbedBuilder() + .setColor(daysIncreased ? 0x00FF00 : 0xFFA500) + .setTitle('🔥 Racha Diaria') + .setDescription(`${message.author.username}, aquí está tu racha:`) + .setThumbnail(message.author.displayAvatarURL({ size: 128 })); + + // Racha actual + embed.addFields( + { + name: '🔥 Racha Actual', + value: `**${streak.currentStreak}** días consecutivos`, + inline: true + }, + { + name: '⭐ Mejor Racha', + value: `**${streak.longestStreak}** días`, + inline: true + }, + { + name: '📅 Días Activos', + value: `**${streak.totalDaysActive}** días totales`, + inline: true + } + ); + + // Mensaje de estado + if (newDay) { + if (daysIncreased) { + embed.addFields({ + name: '✅ ¡Racha Incrementada!', + value: `Has mantenido tu racha por **${streak.currentStreak}** días seguidos.`, + inline: false + }); + } else { + embed.addFields({ + name: '⚠️ Racha Reiniciada', + value: 'Pasó más de un día sin actividad. Tu racha se ha reiniciado.', + inline: false + }); + } + + // Mostrar recompensas + if (rewards) { + let rewardsText = ''; + if (rewards.coins) rewardsText += `💰 **${rewards.coins.toLocaleString()}** monedas\n`; + if (rewards.items) { + rewards.items.forEach(item => { + rewardsText += `📦 **${item.quantity}x** ${item.key}\n`; + }); + } + + if (rewardsText) { + embed.addFields({ + name: '🎁 Recompensa del Día', + value: rewardsText, + inline: false + }); + } + } + } else { + embed.addFields({ + name: 'ℹ️ Ya Reclamaste Hoy', + value: 'Ya has reclamado tu recompensa diaria. Vuelve mañana para continuar tu racha.', + inline: false + }); + } + + // Próximos hitos + const milestones = [3, 7, 14, 30, 60, 90, 180, 365]; + const nextMilestone = milestones.find(m => m > streak.currentStreak); + + if (nextMilestone) { + const remaining = nextMilestone - streak.currentStreak; + embed.addFields({ + name: '🎯 Próximo Hito', + value: `Faltan **${remaining}** días para alcanzar el día **${nextMilestone}**`, + inline: false + }); + } + + embed.setFooter({ text: 'Juega todos los días para mantener tu racha activa' }); + embed.setTimestamp(); + + await message.reply({ embeds: [embed] }); + } catch (error) { + console.error('Error en comando racha:', error); + await message.reply('❌ Error al obtener tu racha diaria.'); + } + } +}; diff --git a/src/commands/messages/game/stats.ts b/src/commands/messages/game/stats.ts new file mode 100644 index 0000000..5ce0271 --- /dev/null +++ b/src/commands/messages/game/stats.ts @@ -0,0 +1,77 @@ +import type { CommandMessage } from '../../../core/types/commands'; +import type Amayo from '../../../core/client'; +import { getPlayerStatsFormatted } from '../../../game/stats/service'; +import { EmbedBuilder } from 'discord.js'; + +export const command: CommandMessage = { + name: 'stats', + type: 'message', + aliases: ['estadisticas', 'est'], + cooldown: 5, + description: 'Ver estadísticas detalladas de un jugador', + usage: 'stats [@usuario]', + run: async (message, args, client: Amayo) => { + try { + const guildId = message.guild!.id; + const targetUser = message.mentions.users.first() || message.author; + const userId = targetUser.id; + + // Obtener estadísticas formateadas + const stats = await getPlayerStatsFormatted(userId, guildId); + + // Crear embed + const embed = new EmbedBuilder() + .setColor(0x5865F2) + .setTitle(`📊 Estadísticas de ${targetUser.username}`) + .setThumbnail(targetUser.displayAvatarURL({ size: 128 })) + .setTimestamp(); + + // Actividades + if (stats.activities) { + const activitiesText = Object.entries(stats.activities) + .map(([key, value]) => `${key}: **${value.toLocaleString()}**`) + .join('\n'); + embed.addFields({ name: '🎮 Actividades', value: activitiesText || 'Sin datos', inline: true }); + } + + // Combate + if (stats.combat) { + const combatText = Object.entries(stats.combat) + .map(([key, value]) => `${key}: **${value.toLocaleString()}**`) + .join('\n'); + embed.addFields({ name: '⚔️ Combate', value: combatText || 'Sin datos', inline: true }); + } + + // Economía + if (stats.economy) { + const economyText = Object.entries(stats.economy) + .map(([key, value]) => `${key}: **${value.toLocaleString()}**`) + .join('\n'); + embed.addFields({ name: '💰 Economía', value: economyText || 'Sin datos', inline: false }); + } + + // Items + if (stats.items) { + const itemsText = Object.entries(stats.items) + .map(([key, value]) => `${key}: **${value.toLocaleString()}**`) + .join('\n'); + embed.addFields({ name: '📦 Items', value: itemsText || 'Sin datos', inline: true }); + } + + // Récords + if (stats.records) { + const recordsText = Object.entries(stats.records) + .map(([key, value]) => `${key}: **${value.toLocaleString()}**`) + .join('\n'); + embed.addFields({ name: '🏆 Récords', value: recordsText || 'Sin datos', inline: true }); + } + + embed.setFooter({ text: `Usa ${client.prefix}ranking-stats para ver el ranking global` }); + + await message.reply({ embeds: [embed] }); + } catch (error) { + console.error('Error en comando stats:', error); + await message.reply('❌ Error al obtener las estadísticas.'); + } + } +}; diff --git a/src/game/achievements/service.ts b/src/game/achievements/service.ts new file mode 100644 index 0000000..728b291 --- /dev/null +++ b/src/game/achievements/service.ts @@ -0,0 +1,244 @@ +import { prisma } from '../../core/database/prisma'; +import { giveRewards, type Reward } from '../rewards/service'; +import { getOrCreatePlayerStats } from '../stats/service'; +import logger from '../../core/lib/logger'; + +/** + * Verificar y desbloquear logros según un trigger + */ +export async function checkAchievements( + userId: string, + guildId: string, + trigger: string +): Promise { + try { + // Obtener todos los logros del servidor que no estén desbloqueados + const achievements = await prisma.achievement.findMany({ + where: { + OR: [{ guildId }, { guildId: null }], + unlocked: { + none: { + userId, + guildId, + unlockedAt: { not: null } + } + } + } + }); + + const newUnlocks = []; + const stats = await getOrCreatePlayerStats(userId, guildId); + + for (const achievement of achievements) { + const req = achievement.requirements as any; + + // Verificar si el trigger coincide + if (req.type !== trigger) continue; + + // Obtener o crear progreso del logro + let progress = await prisma.playerAchievement.findUnique({ + where: { + userId_guildId_achievementId: { + userId, + guildId, + achievementId: achievement.id + } + } + }); + + if (!progress) { + progress = await prisma.playerAchievement.create({ + data: { + userId, + guildId, + achievementId: achievement.id, + progress: 0 + } + }); + } + + // Ya desbloqueado + if (progress.unlockedAt) continue; + + // Obtener el valor actual según el tipo de requisito + let currentValue = 0; + switch (req.type) { + case 'mine_count': + currentValue = stats.minesCompleted; + break; + case 'fish_count': + currentValue = stats.fishingCompleted; + break; + case 'fight_count': + currentValue = stats.fightsCompleted; + break; + case 'farm_count': + currentValue = stats.farmsCompleted; + break; + case 'mob_defeat_count': + currentValue = stats.mobsDefeated; + break; + case 'craft_count': + currentValue = stats.itemsCrafted; + break; + case 'coins_earned': + currentValue = stats.totalCoinsEarned; + break; + case 'damage_dealt': + currentValue = stats.damageDealt; + break; + default: + continue; + } + + // Actualizar progreso + await prisma.playerAchievement.update({ + where: { id: progress.id }, + data: { progress: currentValue } + }); + + // Verificar si se desbloqueó + if (currentValue >= req.value) { + await prisma.playerAchievement.update({ + where: { id: progress.id }, + data: { + unlockedAt: new Date(), + progress: req.value + } + }); + + // Dar recompensas si las hay + if (achievement.rewards) { + await giveRewards(userId, guildId, achievement.rewards as Reward, `achievement:${achievement.key}`); + } + + newUnlocks.push(achievement); + } + } + + return newUnlocks; + } catch (error) { + logger.error(`Error checking achievements for ${userId}:`, error); + return []; + } +} + +/** + * Obtener logros desbloqueados de un jugador + */ +export async function getPlayerAchievements(userId: string, guildId: string) { + const unlocked = await prisma.playerAchievement.findMany({ + where: { + userId, + guildId, + unlockedAt: { not: null } + }, + include: { + achievement: true + }, + orderBy: { + unlockedAt: 'desc' + } + }); + + const inProgress = await prisma.playerAchievement.findMany({ + where: { + userId, + guildId, + unlockedAt: null + }, + include: { + achievement: true + }, + orderBy: { + progress: 'desc' + } + }); + + return { unlocked, inProgress }; +} + +/** + * Obtener progreso de un logro específico + */ +export async function getAchievementProgress( + userId: string, + guildId: string, + achievementKey: string +) { + const achievement = await prisma.achievement.findUnique({ + where: { guildId_key: { guildId, key: achievementKey } } + }); + + if (!achievement) return null; + + const progress = await prisma.playerAchievement.findUnique({ + where: { + userId_guildId_achievementId: { + userId, + guildId, + achievementId: achievement.id + } + } + }); + + if (!progress) return { current: 0, required: (achievement.requirements as any).value, percentage: 0 }; + + const required = (achievement.requirements as any).value; + const percentage = Math.min(100, Math.floor((progress.progress / required) * 100)); + + return { + current: progress.progress, + required, + percentage, + unlocked: !!progress.unlockedAt + }; +} + +/** + * Crear barra de progreso visual + */ +export function createProgressBar(current: number, total: number, width: number = 10): string { + const filled = Math.floor((current / total) * width); + const empty = Math.max(0, width - filled); + const percentage = Math.min(100, Math.floor((current / total) * 100)); + return `${'█'.repeat(filled)}${'░'.repeat(empty)} ${percentage}%`; +} + +/** + * Obtener estadísticas de logros del jugador + */ +export async function getAchievementStats(userId: string, guildId: string) { + const total = await prisma.achievement.count({ + where: { OR: [{ guildId }, { guildId: null }] } + }); + + const unlocked = await prisma.playerAchievement.count({ + where: { + userId, + guildId, + unlockedAt: { not: null } + } + }); + + const totalPoints = await prisma.playerAchievement.findMany({ + where: { + userId, + guildId, + unlockedAt: { not: null } + }, + include: { + achievement: true + } + }).then(achievements => + achievements.reduce((sum, pa) => sum + (pa.achievement.points || 0), 0) + ); + + return { + total, + unlocked, + locked: total - unlocked, + percentage: total > 0 ? Math.floor((unlocked / total) * 100) : 0, + points: totalPoints + }; +} diff --git a/src/game/quests/service.ts b/src/game/quests/service.ts new file mode 100644 index 0000000..285eb1b --- /dev/null +++ b/src/game/quests/service.ts @@ -0,0 +1,300 @@ +import { prisma } from '../../core/database/prisma'; +import { giveRewards, type Reward } from '../rewards/service'; +import logger from '../../core/lib/logger'; + +/** + * Actualizar progreso de misiones del jugador + */ +export async function updateQuestProgress( + userId: string, + guildId: string, + questType: string, + increment: number = 1 +) { + try { + // Obtener misiones activas que coincidan con el tipo + const quests = await prisma.quest.findMany({ + where: { + OR: [{ guildId }, { guildId: null }], + active: true, + OR: [ + { endAt: null }, + { endAt: { gte: new Date() } } + ] + } + }); + + const updates = []; + + for (const quest of quests) { + const req = quest.requirements as any; + + // Verificar si el tipo de misión coincide + if (req.type !== questType) continue; + + // Obtener o crear progreso + let progress = await prisma.questProgress.findFirst({ + where: { + userId, + guildId, + questId: quest.id, + claimed: false + }, + orderBy: { createdAt: 'desc' } + }); + + if (!progress) { + // Crear nuevo progreso + progress = await prisma.questProgress.create({ + data: { + userId, + guildId, + questId: quest.id, + progress: 0, + expiresAt: quest.endAt + } + }); + } + + // Ya completada y reclamada + if (progress.completed && progress.claimed) { + // Si es repetible, crear nuevo progreso + if (quest.repeatable) { + progress = await prisma.questProgress.create({ + data: { + userId, + guildId, + questId: quest.id, + progress: 0, + expiresAt: quest.endAt + } + }); + } else { + continue; + } + } + + // Actualizar progreso + const newProgress = progress.progress + increment; + const isCompleted = newProgress >= req.count; + + await prisma.questProgress.update({ + where: { id: progress.id }, + data: { + progress: newProgress, + completed: isCompleted, + completedAt: isCompleted ? new Date() : null + } + }); + + if (isCompleted) { + updates.push(quest); + } + } + + return updates; + } catch (error) { + logger.error(`Error updating quest progress for ${userId}:`, error); + return []; + } +} + +/** + * Reclamar recompensa de misión completada + */ +export async function claimQuestReward( + userId: string, + guildId: string, + questId: string +) { + try { + const progress = await prisma.questProgress.findFirst({ + where: { + userId, + guildId, + questId, + completed: true, + claimed: false + }, + include: { + quest: true + } + }); + + if (!progress) { + throw new Error('Misión no encontrada o ya reclamada'); + } + + // Dar recompensas + const rewards = await giveRewards( + userId, + guildId, + progress.quest.rewards as Reward, + `quest:${progress.quest.key}` + ); + + // Marcar como reclamada + await prisma.questProgress.update({ + where: { id: progress.id }, + data: { + claimed: true, + claimedAt: new Date() + } + }); + + return { quest: progress.quest, rewards }; + } catch (error) { + logger.error(`Error claiming quest reward for ${userId}:`, error); + throw error; + } +} + +/** + * Obtener misiones disponibles y progreso del jugador + */ +export async function getPlayerQuests(userId: string, guildId: string) { + const quests = await prisma.quest.findMany({ + where: { + OR: [{ guildId }, { guildId: null }], + active: true, + OR: [ + { endAt: null }, + { endAt: { gte: new Date() } } + ] + }, + orderBy: [ + { type: 'asc' }, + { category: 'asc' } + ] + }); + + const questsWithProgress = await Promise.all( + quests.map(async (quest) => { + const progress = await prisma.questProgress.findFirst({ + where: { + userId, + guildId, + questId: quest.id + }, + orderBy: { createdAt: 'desc' } + }); + + return { + quest, + progress: progress || null, + canClaim: progress?.completed && !progress?.claimed, + percentage: progress + ? Math.min(100, Math.floor((progress.progress / (quest.requirements as any).count) * 100)) + : 0 + }; + }) + ); + + // Agrupar por tipo + return { + daily: questsWithProgress.filter(q => q.quest.type === 'daily'), + weekly: questsWithProgress.filter(q => q.quest.type === 'weekly'), + permanent: questsWithProgress.filter(q => q.quest.type === 'permanent'), + event: questsWithProgress.filter(q => q.quest.type === 'event') + }; +} + +/** + * Generar misiones diarias aleatorias + */ +export async function generateDailyQuests(guildId: string) { + try { + // Eliminar misiones diarias antiguas + await prisma.quest.deleteMany({ + where: { + guildId, + type: 'daily', + endAt: { lt: new Date() } + } + }); + + // Templates de misiones diarias + const dailyTemplates = [ + { + key: 'daily_mine', + name: 'Minero Diario', + description: 'Mina 10 veces', + category: 'mining', + requirements: { type: 'mine_count', count: 10 }, + rewards: { coins: 500 } + }, + { + key: 'daily_fish', + name: 'Pescador Diario', + description: 'Pesca 8 veces', + category: 'fishing', + requirements: { type: 'fish_count', count: 8 }, + rewards: { coins: 400 } + }, + { + key: 'daily_fight', + name: 'Guerrero Diario', + description: 'Pelea 5 veces', + category: 'combat', + requirements: { type: 'fight_count', count: 5 }, + rewards: { coins: 600 } + }, + { + key: 'daily_craft', + name: 'Artesano Diario', + description: 'Craftea 3 items', + category: 'crafting', + requirements: { type: 'craft_count', count: 3 }, + rewards: { coins: 300 } + } + ]; + + // Crear 3 misiones diarias aleatorias + const selectedTemplates = dailyTemplates + .sort(() => Math.random() - 0.5) + .slice(0, 3); + + const startAt = new Date(); + const endAt = new Date(); + endAt.setHours(23, 59, 59, 999); + + for (const template of selectedTemplates) { + await prisma.quest.create({ + data: { + ...template, + guildId, + type: 'daily', + startAt, + endAt, + active: true, + repeatable: false + } + }); + } + + logger.info(`Generated ${selectedTemplates.length} daily quests for guild ${guildId}`); + return selectedTemplates.length; + } catch (error) { + logger.error(`Error generating daily quests for ${guildId}:`, error); + return 0; + } +} + +/** + * Limpiar misiones expiradas + */ +export async function cleanExpiredQuests(guildId: string) { + const result = await prisma.quest.updateMany({ + where: { + guildId, + active: true, + endAt: { lt: new Date() } + }, + data: { + active: false + } + }); + + logger.info(`Deactivated ${result.count} expired quests for guild ${guildId}`); + return result.count; +} diff --git a/src/game/rewards/service.ts b/src/game/rewards/service.ts new file mode 100644 index 0000000..4286258 --- /dev/null +++ b/src/game/rewards/service.ts @@ -0,0 +1,95 @@ +import { prisma } from '../../core/database/prisma'; +import { addItemByKey, adjustCoins } from '../economy/service'; +import logger from '../../core/lib/logger'; + +export interface Reward { + coins?: number; + items?: Array<{ key: string; quantity: number }>; + xp?: number; + title?: string; +} + +/** + * Dar recompensas a un jugador + */ +export async function giveRewards( + userId: string, + guildId: string, + rewards: Reward, + source: string +): Promise { + const results: string[] = []; + + try { + // Monedas + if (rewards.coins && rewards.coins > 0) { + await adjustCoins(userId, guildId, rewards.coins); + results.push(`💰 **${rewards.coins.toLocaleString()}** monedas`); + } + + // Items + if (rewards.items && rewards.items.length > 0) { + for (const item of rewards.items) { + await addItemByKey(userId, guildId, item.key, item.quantity); + results.push(`📦 **${item.quantity}x** ${item.key}`); + } + } + + // XP (por implementar si tienes sistema de XP) + if (rewards.xp && rewards.xp > 0) { + results.push(`⭐ **${rewards.xp}** XP`); + } + + // Título (por implementar) + if (rewards.title) { + results.push(`🏆 Título: **${rewards.title}**`); + } + + // Log de auditoría + await prisma.auditLog.create({ + data: { + userId, + guildId, + action: 'reward_given', + target: source, + details: rewards + } + }).catch(() => {}); // Silencioso si falla el log + + logger.info(`Rewards given to ${userId} in ${guildId} from ${source}:`, rewards); + + return results; + } catch (error) { + logger.error(`Error giving rewards to ${userId} in ${guildId}:`, error); + throw error; + } +} + +/** + * Validar que las recompensas sean válidas + */ +export function validateRewards(rewards: any): rewards is Reward { + if (typeof rewards !== 'object' || rewards === null) return false; + + if (rewards.coins !== undefined && (typeof rewards.coins !== 'number' || rewards.coins < 0)) { + return false; + } + + if (rewards.items !== undefined) { + if (!Array.isArray(rewards.items)) return false; + for (const item of rewards.items) { + if (!item.key || typeof item.key !== 'string') return false; + if (typeof item.quantity !== 'number' || item.quantity <= 0) return false; + } + } + + if (rewards.xp !== undefined && (typeof rewards.xp !== 'number' || rewards.xp < 0)) { + return false; + } + + if (rewards.title !== undefined && typeof rewards.title !== 'string') { + return false; + } + + return true; +} diff --git a/src/game/stats/service.ts b/src/game/stats/service.ts new file mode 100644 index 0000000..1e2b9bf --- /dev/null +++ b/src/game/stats/service.ts @@ -0,0 +1,183 @@ +import { prisma } from '../../core/database/prisma'; +import type { Prisma } from '@prisma/client'; +import logger from '../../core/lib/logger'; + +/** + * Obtener o crear las estadísticas de un jugador + */ +export async function getOrCreatePlayerStats(userId: string, guildId: string) { + let stats = await prisma.playerStats.findUnique({ + where: { userId_guildId: { userId, guildId } } + }); + + if (!stats) { + stats = await prisma.playerStats.create({ + data: { userId, guildId } + }); + } + + return stats; +} + +/** + * Actualizar estadísticas del jugador + */ +export async function updateStats( + userId: string, + guildId: string, + updates: Partial> +) { + try { + await getOrCreatePlayerStats(userId, guildId); + + // Convertir incrementos a operaciones atómicas + const incrementData: any = {}; + const setData: any = {}; + + for (const [key, value] of Object.entries(updates)) { + if (typeof value === 'number') { + // Si es un número, incrementar + incrementData[key] = value; + } else { + // Si no, establecer valor + setData[key] = value; + } + } + + const updateData: any = {}; + if (Object.keys(incrementData).length > 0) { + updateData.increment = incrementData; + } + if (Object.keys(setData).length > 0) { + Object.assign(updateData, setData); + } + + const stats = await prisma.playerStats.update({ + where: { userId_guildId: { userId, guildId } }, + data: updateData + }); + + // Verificar récords + if (updates.damageDealt && typeof updates.damageDealt === 'number') { + if (updates.damageDealt > stats.highestDamageDealt) { + await prisma.playerStats.update({ + where: { userId_guildId: { userId, guildId } }, + data: { highestDamageDealt: updates.damageDealt } + }); + } + } + + return stats; + } catch (error) { + logger.error(`Error updating stats for ${userId} in ${guildId}:`, error); + throw error; + } +} + +/** + * Incrementar contador específico + */ +export async function incrementStat( + userId: string, + guildId: string, + stat: string, + amount: number = 1 +) { + await getOrCreatePlayerStats(userId, guildId); + + return await prisma.playerStats.update({ + where: { userId_guildId: { userId, guildId } }, + data: { [stat]: { increment: amount } } + }); +} + +/** + * Obtener leaderboard por categoría + */ +export async function getLeaderboard( + guildId: string, + category: keyof Omit, + limit: number = 10 +) { + const stats = await prisma.playerStats.findMany({ + where: { guildId }, + orderBy: { [category]: 'desc' }, + take: limit, + include: { + user: true + } + }); + + return stats.filter(s => (s[category] as number) > 0); +} + +/** + * Obtener estadísticas de un jugador con formato amigable + */ +export async function getPlayerStatsFormatted(userId: string, guildId: string) { + const stats = await getOrCreatePlayerStats(userId, guildId); + + return { + activities: { + '⛏️ Minas': stats.minesCompleted, + '🎣 Pesca': stats.fishingCompleted, + '⚔️ Combates': stats.fightsCompleted, + '🌾 Granja': stats.farmsCompleted + }, + combat: { + '👾 Mobs Derrotados': stats.mobsDefeated, + '💥 Daño Infligido': stats.damageDealt, + '🩹 Daño Recibido': stats.damageTaken, + '💀 Veces Derrotado': stats.timesDefeated, + '🏆 Racha de Victorias': stats.currentWinStreak, + '⭐ Mejor Racha': stats.longestWinStreak + }, + economy: { + '💰 Monedas Ganadas': stats.totalCoinsEarned, + '💸 Monedas Gastadas': stats.totalCoinsSpent, + '🛠️ Items Crafteados': stats.itemsCrafted, + '🔥 Items Fundidos': stats.itemsSmelted, + '🛒 Items Comprados': stats.itemsPurchased + }, + items: { + '📦 Cofres Abiertos': stats.chestsOpened, + '🍖 Items Consumidos': stats.itemsConsumed, + '⚔️ Items Equipados': stats.itemsEquipped + }, + records: { + '💥 Mayor Daño': stats.highestDamageDealt, + '💰 Más Monedas': stats.mostCoinsAtOnce + } + }; +} + +/** + * Resetear estadísticas de un jugador + */ +export async function resetPlayerStats(userId: string, guildId: string) { + return await prisma.playerStats.update({ + where: { userId_guildId: { userId, guildId } }, + data: { + minesCompleted: 0, + fishingCompleted: 0, + fightsCompleted: 0, + farmsCompleted: 0, + mobsDefeated: 0, + damageDealt: 0, + damageTaken: 0, + timesDefeated: 0, + totalCoinsEarned: 0, + totalCoinsSpent: 0, + itemsCrafted: 0, + itemsSmelted: 0, + itemsPurchased: 0, + chestsOpened: 0, + itemsConsumed: 0, + itemsEquipped: 0, + highestDamageDealt: 0, + longestWinStreak: 0, + currentWinStreak: 0, + mostCoinsAtOnce: 0 + } + }); +} diff --git a/src/game/stats/types.ts b/src/game/stats/types.ts new file mode 100644 index 0000000..ff63011 --- /dev/null +++ b/src/game/stats/types.ts @@ -0,0 +1,41 @@ +export interface StatsUpdate { + // Minijuegos + minesCompleted?: number; + fishingCompleted?: number; + fightsCompleted?: number; + farmsCompleted?: number; + + // Combate + mobsDefeated?: number; + damageDealt?: number; + damageTaken?: number; + timesDefeated?: number; + + // Economía + totalCoinsEarned?: number; + totalCoinsSpent?: number; + itemsCrafted?: number; + itemsSmelted?: number; + itemsPurchased?: number; + + // Items + chestsOpened?: number; + itemsConsumed?: number; + itemsEquipped?: number; + + // Récords + highestDamageDealt?: number; + longestWinStreak?: number; + currentWinStreak?: number; + mostCoinsAtOnce?: number; +} + +export type StatCategory = + | 'minesCompleted' + | 'fishingCompleted' + | 'fightsCompleted' + | 'farmsCompleted' + | 'mobsDefeated' + | 'damageDealt' + | 'totalCoinsEarned' + | 'itemsCrafted'; diff --git a/src/game/streaks/service.ts b/src/game/streaks/service.ts new file mode 100644 index 0000000..27d9830 --- /dev/null +++ b/src/game/streaks/service.ts @@ -0,0 +1,170 @@ +import { prisma } from '../../core/database/prisma'; +import { giveRewards, type Reward } from '../rewards/service'; +import logger from '../../core/lib/logger'; + +/** + * Obtener o crear racha del jugador + */ +export async function getOrCreateStreak(userId: string, guildId: string) { + let streak = await prisma.playerStreak.findUnique({ + where: { userId_guildId: { userId, guildId } } + }); + + if (!streak) { + streak = await prisma.playerStreak.create({ + data: { + userId, + guildId, + currentStreak: 0, + longestStreak: 0, + lastActiveDate: new Date(), + totalDaysActive: 0 + } + }); + } + + return streak; +} + +/** + * Actualizar racha diaria del jugador + */ +export async function updateStreak(userId: string, guildId: string) { + try { + const streak = await getOrCreateStreak(userId, guildId); + const now = new Date(); + const lastActive = new Date(streak.lastActiveDate); + + // Resetear hora para comparar solo la fecha + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const lastDay = new Date(lastActive.getFullYear(), lastActive.getMonth(), lastActive.getDate()); + + const daysDiff = Math.floor((today.getTime() - lastDay.getTime()) / (1000 * 60 * 60 * 24)); + + // Ya actualizó hoy + if (daysDiff === 0) { + return { streak, newDay: false, rewards: null }; + } + + let newStreak = streak.currentStreak; + + // Si pasó 1 día exacto, incrementar racha + if (daysDiff === 1) { + newStreak = streak.currentStreak + 1; + } + // Si pasó más de 1 día, resetear racha + else if (daysDiff > 1) { + newStreak = 1; + } + + // Actualizar longest streak si es necesario + const newLongest = Math.max(streak.longestStreak, newStreak); + + const updated = await prisma.playerStreak.update({ + where: { userId_guildId: { userId, guildId } }, + data: { + currentStreak: newStreak, + longestStreak: newLongest, + lastActiveDate: now, + totalDaysActive: { increment: 1 } + } + }); + + // Obtener recompensa del día + const reward = getStreakReward(newStreak); + + // Dar recompensas + if (reward) { + await giveRewards(userId, guildId, reward, `streak:day${newStreak}`); + } + + return { + streak: updated, + newDay: true, + rewards: reward, + daysIncreased: daysDiff === 1 + }; + } catch (error) { + logger.error(`Error updating streak for ${userId}:`, error); + throw error; + } +} + +/** + * Obtener recompensa según el día de racha + */ +export function getStreakReward(day: number): Reward | null { + // Recompensas especiales por día + const specialDays: Record = { + 1: { coins: 100 }, + 3: { coins: 300 }, + 5: { coins: 500 }, + 7: { coins: 1000 }, + 10: { coins: 1500 }, + 14: { coins: 2500 }, + 21: { coins: 5000 }, + 30: { coins: 10000 }, + 60: { coins: 25000 }, + 90: { coins: 50000 }, + 180: { coins: 100000 }, + 365: { coins: 500000 } + }; + + // Si hay recompensa especial para este día + if (specialDays[day]) { + return specialDays[day]; + } + + // Recompensa base diaria (escala con el día) + const baseCoins = 50; + const bonus = Math.floor(day / 7) * 50; // +50 monedas cada 7 días + + return { + coins: baseCoins + bonus + }; +} + +/** + * Obtener información de la racha con recompensas próximas + */ +export async function getStreakInfo(userId: string, guildId: string) { + const streak = await getOrCreateStreak(userId, guildId); + const now = new Date(); + const lastActive = new Date(streak.lastActiveDate); + + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const lastDay = new Date(lastActive.getFullYear(), lastActive.getMonth(), lastActive.getDate()); + + const daysDiff = Math.floor((today.getTime() - lastDay.getTime()) / (1000 * 60 * 60 * 24)); + + // Calcular si la racha está activa o expiró + const isActive = daysDiff <= 1; + const willExpireSoon = daysDiff === 0; // Ya jugó hoy + + // Próxima recompensa especial + const specialDays = [1, 3, 5, 7, 10, 14, 21, 30, 60, 90, 180, 365]; + const nextMilestone = specialDays.find(d => d > streak.currentStreak) || null; + + return { + streak, + isActive, + willExpireSoon, + daysDiff, + nextMilestone, + nextMilestoneIn: nextMilestone ? nextMilestone - streak.currentStreak : null, + todayReward: getStreakReward(streak.currentStreak + 1) + }; +} + +/** + * Resetear racha de un jugador (admin) + */ +export async function resetStreak(userId: string, guildId: string) { + return await prisma.playerStreak.update({ + where: { userId_guildId: { userId, guildId } }, + data: { + currentStreak: 0, + lastActiveDate: new Date() + } + }); +}