feat(economy): implement streak and stats commands with detailed user feedback
This commit is contained in:
110
src/commands/messages/game/racha.ts
Normal file
110
src/commands/messages/game/racha.ts
Normal file
@@ -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.');
|
||||
}
|
||||
}
|
||||
};
|
||||
77
src/commands/messages/game/stats.ts
Normal file
77
src/commands/messages/game/stats.ts
Normal file
@@ -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.');
|
||||
}
|
||||
}
|
||||
};
|
||||
244
src/game/achievements/service.ts
Normal file
244
src/game/achievements/service.ts
Normal file
@@ -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<any[]> {
|
||||
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
|
||||
};
|
||||
}
|
||||
300
src/game/quests/service.ts
Normal file
300
src/game/quests/service.ts
Normal file
@@ -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;
|
||||
}
|
||||
95
src/game/rewards/service.ts
Normal file
95
src/game/rewards/service.ts
Normal file
@@ -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<string[]> {
|
||||
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;
|
||||
}
|
||||
183
src/game/stats/service.ts
Normal file
183
src/game/stats/service.ts
Normal file
@@ -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<Omit<Prisma.PlayerStatsUpdateInput, 'user' | 'guild' | 'createdAt' | 'updatedAt'>>
|
||||
) {
|
||||
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<Prisma.PlayerStatsCreateInput, 'user' | 'guild'>,
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
41
src/game/stats/types.ts
Normal file
41
src/game/stats/types.ts
Normal file
@@ -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';
|
||||
170
src/game/streaks/service.ts
Normal file
170
src/game/streaks/service.ts
Normal file
@@ -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<number, Reward> = {
|
||||
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()
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user