feat(economy): implement streak and stats commands with detailed user feedback

This commit is contained in:
2025-10-05 05:28:07 -05:00
parent c868e3bf80
commit b10cc89583
9 changed files with 1862 additions and 1 deletions

View 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.');
}
}
};

View 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.');
}
}
};

View 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
View 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;
}

View 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
View 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
View 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
View 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()
}
});
}