Changes include: - Updated architecture documentation - Enhanced module definitions (OQI-001 to OQI-008) - ML integration documentation updates - Trading strategies documentation - Orchestration and inventory updates - Docker configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
34 KiB
34 KiB
| id | title | type | status | rf_parent | epic | version | created_date | updated_date |
|---|---|---|---|---|---|---|---|---|
| ET-EDU-006 | Gamification System | Specification | Done | RF-EDU-006 | OQI-002 | 1.0 | 2025-12-05 | 2026-01-04 |
ET-EDU-006: Sistema de Gamificación
Versión: 1.0.0 Fecha: 2025-12-05 Épica: OQI-002 - Módulo Educativo Componente: Backend/Frontend
Descripción
Define el sistema completo de gamificación del módulo educativo, incluyendo sistema de XP, niveles, badges/achievements, rachas (streaks), leaderboards y recompensas. El objetivo es aumentar el engagement y motivación del usuario mediante mecánicas de juego.
Arquitectura
┌─────────────────────────────────────────────────────────────────┐
│ Gamification System Architecture │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ User Actions │ │
│ │ - Complete lesson │ │
│ │ - Pass quiz │ │
│ │ │ - Complete course │ │
│ │ - Daily login │ │
│ │ - Perfect quiz score │ │
│ └──────────────────┬───────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Gamification Engine │ │
│ │ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ XP Manager │ │Achievement │ │Streak Manager│ │ │
│ │ │ - Calculate │ │Manager │ │- Track daily │ │ │
│ │ │ - Award │ │- Check rules │ │- Update │ │ │
│ │ │ - Level up │ │- Unlock │ │- Rewards │ │ │
│ │ └─────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌─────────────┐ ┌──────────────┐ │ │
│ │ │Leaderboard │ │Notification │ │ │
│ │ │Manager │ │Manager │ │ │
│ │ │- Rankings │ │- Push alerts │ │ │
│ │ │- Periods │ │- Celebrations│ │ │
│ │ └─────────────┘ └──────────────┘ │ │
│ └──────────────────────┬───────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Database │ │
│ │ - user_gamification_profile │ │
│ │ - user_achievements │ │
│ │ - user_activity_log │ │
│ │ - leaderboard_cache │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Frontend Components │ │
│ │ - XPBar, LevelBadge │ │
│ │ - AchievementCard, AchievementModal │ │
│ │ - StreakCounter, Leaderboard │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Especificación Detallada
1. SISTEMA DE XP (EXPERIENCE POINTS)
1.1 Fuentes de XP
// XP Sources Configuration
export const XP_SOURCES = {
// Lessons
LESSON_COMPLETE: 10,
LESSON_FIRST_VIEW: 5,
// Quizzes
QUIZ_PASS: 50,
QUIZ_PERFECT_SCORE: 100, // 50 base + 50 bonus
QUIZ_RETRY_SUCCESS: 25,
// Courses
COURSE_COMPLETE: 500,
COURSE_COMPLETE_FAST: 750, // Completar en menos de una semana
// Daily Activity
DAILY_LOGIN: 5,
DAILY_LESSON: 15,
DAILY_QUIZ: 30,
// Streaks
STREAK_7_DAYS: 100,
STREAK_30_DAYS: 500,
STREAK_100_DAYS: 2000,
// Social
COMMENT_POST: 2,
HELPFUL_COMMENT: 10, // Cuando alguien marca como útil
// Special Events
WEEKEND_BONUS: 1.5, // Multiplicador
CHALLENGE_WIN: 1000
} as const;
1.2 XP Manager Service
// services/gamification/xp-manager.service.ts
export class XPManagerService {
/**
* Otorga XP al usuario
*/
async awardXP(
userId: string,
amount: number,
source: string,
metadata?: any
): Promise<XPAwardResult> {
// 1. Obtener perfil actual
const profile = await this.getUserProfile(userId);
// 2. Calcular XP con multiplicadores
const finalAmount = this.applyMultipliers(amount, source, profile);
// 3. Actualizar XP total
const newTotalXP = profile.total_xp + finalAmount;
// 4. Calcular nivel actual y nuevo
const currentLevel = this.calculateLevel(profile.total_xp);
const newLevel = this.calculateLevel(newTotalXP);
const leveledUp = newLevel > currentLevel;
// 5. Actualizar en BD
await db.user_gamification_profile.update(userId, {
total_xp: newTotalXP,
current_level: newLevel,
updated_at: new Date()
});
// 6. Registrar en activity log
await this.logActivity(userId, {
type: 'xp_earned',
source,
amount: finalAmount,
metadata
});
// 7. Si subió de nivel, crear logro
if (leveledUp) {
await this.createLevelUpAchievement(userId, newLevel);
}
return {
xp_earned: finalAmount,
total_xp: newTotalXP,
previous_level: currentLevel,
new_level: newLevel,
leveled_up: leveledUp,
multipliers_applied: this.getAppliedMultipliers(source, profile)
};
}
/**
* Calcula nivel basado en XP total
* Fórmula: Level = floor(sqrt(totalXP / 100))
*
* Esto significa:
* - Nivel 1: 100 XP
* - Nivel 2: 400 XP (300 más)
* - Nivel 3: 900 XP (500 más)
* - Nivel 4: 1600 XP (700 más)
* - Nivel 5: 2500 XP (900 más)
* - Nivel 10: 10,000 XP
* - Nivel 20: 40,000 XP
* - Nivel 50: 250,000 XP
* - Nivel 100: 1,000,000 XP
*/
calculateLevel(totalXP: number): number {
return Math.floor(Math.sqrt(totalXP / 100));
}
/**
* Calcula XP necesario para siguiente nivel
*/
calculateXPForNextLevel(currentLevel: number): number {
const nextLevel = currentLevel + 1;
return Math.pow(nextLevel, 2) * 100;
}
/**
* Calcula XP para alcanzar un nivel específico
*/
calculateXPForLevel(level: number): number {
return Math.pow(level, 2) * 100;
}
/**
* Calcula progreso hacia el siguiente nivel
*/
calculateLevelProgress(totalXP: number): LevelProgress {
const currentLevel = this.calculateLevel(totalXP);
const currentLevelXP = this.calculateXPForLevel(currentLevel);
const nextLevelXP = this.calculateXPForLevel(currentLevel + 1);
const xpIntoCurrentLevel = totalXP - currentLevelXP;
const xpNeededForNextLevel = nextLevelXP - currentLevelXP;
const progressPercentage = (xpIntoCurrentLevel / xpNeededForNextLevel) * 100;
return {
current_level: currentLevel,
total_xp: totalXP,
xp_current_level: currentLevelXP,
xp_next_level: nextLevelXP,
xp_into_level: xpIntoCurrentLevel,
xp_needed: xpNeededForNextLevel - xpIntoCurrentLevel,
progress_percentage: progressPercentage
};
}
/**
* Aplica multiplicadores de XP
*/
private applyMultipliers(
baseAmount: number,
source: string,
profile: GamificationProfile
): number {
let amount = baseAmount;
// Multiplicador de fin de semana
if (this.isWeekend() && source.includes('LESSON')) {
amount *= XP_SOURCES.WEEKEND_BONUS;
}
// Multiplicador de streak
if (profile.current_streak_days >= 7) {
amount *= 1.1; // +10%
}
if (profile.current_streak_days >= 30) {
amount *= 1.2; // +20%
}
// Multiplicador de premium (futuro)
if (profile.is_premium) {
amount *= 1.5; // +50%
}
return Math.floor(amount);
}
private isWeekend(): boolean {
const day = new Date().getDay();
return day === 0 || day === 6; // Domingo o Sábado
}
private getAppliedMultipliers(
source: string,
profile: GamificationProfile
): string[] {
const multipliers: string[] = [];
if (this.isWeekend() && source.includes('LESSON')) {
multipliers.push('weekend_bonus');
}
if (profile.current_streak_days >= 30) {
multipliers.push('streak_30_days');
} else if (profile.current_streak_days >= 7) {
multipliers.push('streak_7_days');
}
if (profile.is_premium) {
multipliers.push('premium');
}
return multipliers;
}
/**
* Crea logro de subida de nivel
*/
private async createLevelUpAchievement(
userId: string,
newLevel: number
): Promise<void> {
await db.user_achievements.create({
user_id: userId,
achievement_type: 'level_up',
title: `Nivel ${newLevel} Alcanzado`,
description: `Has alcanzado el nivel ${newLevel}`,
badge_icon_url: `/badges/level-${newLevel}.svg`,
xp_bonus: newLevel * 10, // XP bonus por subir de nivel
metadata: { level: newLevel },
earned_at: new Date()
});
}
private async logActivity(
userId: string,
activity: ActivityLog
): Promise<void> {
await db.user_activity_log.create({
user_id: userId,
...activity,
timestamp: new Date()
});
}
private async getUserProfile(userId: string): Promise<GamificationProfile> {
let profile = await db.user_gamification_profile.findUnique({
where: { user_id: userId }
});
// Crear perfil si no existe
if (!profile) {
profile = await db.user_gamification_profile.create({
user_id: userId,
total_xp: 0,
current_level: 0,
current_streak_days: 0,
longest_streak_days: 0,
last_activity_date: new Date(),
is_premium: false
});
}
return profile;
}
}
interface XPAwardResult {
xp_earned: number;
total_xp: number;
previous_level: number;
new_level: number;
leveled_up: boolean;
multipliers_applied: string[];
}
interface LevelProgress {
current_level: number;
total_xp: number;
xp_current_level: number;
xp_next_level: number;
xp_into_level: number;
xp_needed: number;
progress_percentage: number;
}
interface ActivityLog {
type: string;
source: string;
amount: number;
metadata?: any;
}
interface GamificationProfile {
user_id: string;
total_xp: number;
current_level: number;
current_streak_days: number;
longest_streak_days: number;
last_activity_date: Date;
is_premium: boolean;
}
2. SISTEMA DE ACHIEVEMENTS (LOGROS/BADGES)
2.1 Tipos de Achievements
// config/achievements.config.ts
export const ACHIEVEMENTS = {
// Course Completion
FIRST_COURSE: {
id: 'first_course',
title: 'Primer Paso',
description: 'Completa tu primer curso',
type: 'course_completion',
badge_icon: '/badges/first-course.svg',
xp_bonus: 100,
rarity: 'common'
},
COMPLETE_5_COURSES: {
id: 'complete_5_courses',
title: 'Estudiante Dedicado',
description: 'Completa 5 cursos',
type: 'course_completion',
badge_icon: '/badges/5-courses.svg',
xp_bonus: 500,
rarity: 'uncommon'
},
COMPLETE_ALL_BEGINNER: {
id: 'complete_all_beginner',
title: 'Maestro Principiante',
description: 'Completa todos los cursos para principiantes',
type: 'course_completion',
badge_icon: '/badges/beginner-master.svg',
xp_bonus: 1000,
rarity: 'rare'
},
// Quiz Performance
PERFECT_QUIZ: {
id: 'perfect_quiz',
title: 'Perfeccionista',
description: 'Obtén 100% en un quiz',
type: 'quiz_perfect_score',
badge_icon: '/badges/perfect.svg',
xp_bonus: 50,
rarity: 'common'
},
PERFECT_10_QUIZZES: {
id: 'perfect_10_quizzes',
title: 'Genio',
description: 'Obtén 100% en 10 quizzes',
type: 'quiz_perfect_score',
badge_icon: '/badges/genius.svg',
xp_bonus: 500,
rarity: 'epic'
},
// Streaks
STREAK_7: {
id: 'streak_7',
title: 'Racha Semanal',
description: 'Mantén una racha de 7 días',
type: 'streak_milestone',
badge_icon: '/badges/streak-7.svg',
xp_bonus: 100,
rarity: 'common'
},
STREAK_30: {
id: 'streak_30',
title: 'Racha Mensual',
description: 'Mantén una racha de 30 días',
type: 'streak_milestone',
badge_icon: '/badges/streak-30.svg',
xp_bonus: 500,
rarity: 'rare'
},
STREAK_100: {
id: 'streak_100',
title: 'Imparable',
description: 'Mantén una racha de 100 días',
type: 'streak_milestone',
badge_icon: '/badges/streak-100.svg',
xp_bonus: 2000,
rarity: 'legendary'
},
// Speed
SPEED_DEMON: {
id: 'speed_demon',
title: 'Demonio de Velocidad',
description: 'Completa un curso en menos de 24 horas',
type: 'special_event',
badge_icon: '/badges/speed.svg',
xp_bonus: 300,
rarity: 'rare'
},
// Level Milestones
LEVEL_10: {
id: 'level_10',
title: 'Nivel 10',
description: 'Alcanza el nivel 10',
type: 'level_up',
badge_icon: '/badges/level-10.svg',
xp_bonus: 200,
rarity: 'uncommon'
},
LEVEL_50: {
id: 'level_50',
title: 'Nivel 50',
description: 'Alcanza el nivel 50',
type: 'level_up',
badge_icon: '/badges/level-50.svg',
xp_bonus: 5000,
rarity: 'legendary'
}
} as const;
2.2 Achievement Manager
// services/gamification/achievement-manager.service.ts
export class AchievementManagerService {
/**
* Verifica y otorga achievements basados en una acción
*/
async checkAndAwardAchievements(
userId: string,
event: AchievementEvent
): Promise<Achievement[]> {
const earnedAchievements: Achievement[] = [];
// Obtener achievements ya ganados por el usuario
const existingAchievements = await this.getUserAchievements(userId);
const existingIds = existingAchievements.map(a => a.achievement_id);
// Verificar cada regla de achievement
for (const [key, config] of Object.entries(ACHIEVEMENTS)) {
// Skip si ya fue ganado
if (existingIds.includes(config.id)) continue;
// Verificar si cumple con los criterios
const earned = await this.checkAchievementCriteria(
userId,
config,
event
);
if (earned) {
const achievement = await this.awardAchievement(userId, config);
earnedAchievements.push(achievement);
}
}
return earnedAchievements;
}
/**
* Verifica si se cumplen los criterios para un achievement
*/
private async checkAchievementCriteria(
userId: string,
config: AchievementConfig,
event: AchievementEvent
): Promise<boolean> {
switch (config.id) {
case 'first_course':
return event.type === 'course_completed' &&
await this.getCompletedCoursesCount(userId) === 1;
case 'complete_5_courses':
return event.type === 'course_completed' &&
await this.getCompletedCoursesCount(userId) === 5;
case 'perfect_quiz':
return event.type === 'quiz_completed' &&
event.metadata?.score_percentage === 100;
case 'perfect_10_quizzes':
return event.type === 'quiz_completed' &&
event.metadata?.score_percentage === 100 &&
await this.getPerfectQuizzesCount(userId) === 10;
case 'streak_7':
return event.type === 'daily_activity' &&
event.metadata?.current_streak === 7;
case 'streak_30':
return event.type === 'daily_activity' &&
event.metadata?.current_streak === 30;
case 'streak_100':
return event.type === 'daily_activity' &&
event.metadata?.current_streak === 100;
case 'speed_demon':
if (event.type !== 'course_completed') return false;
const enrollment = await this.getEnrollment(event.metadata?.enrollment_id);
const duration = Date.now() - new Date(enrollment.enrolled_at).getTime();
const hours = duration / (1000 * 60 * 60);
return hours < 24;
case 'level_10':
return event.type === 'level_up' &&
event.metadata?.new_level === 10;
case 'level_50':
return event.type === 'level_up' &&
event.metadata?.new_level === 50;
default:
return false;
}
}
/**
* Otorga un achievement al usuario
*/
private async awardAchievement(
userId: string,
config: AchievementConfig
): Promise<Achievement> {
const achievement = await db.user_achievements.create({
user_id: userId,
achievement_id: config.id,
achievement_type: config.type,
title: config.title,
description: config.description,
badge_icon_url: config.badge_icon,
xp_bonus: config.xp_bonus,
rarity: config.rarity,
earned_at: new Date()
});
// Otorgar XP bonus
const xpManager = new XPManagerService();
await xpManager.awardXP(
userId,
config.xp_bonus,
`achievement_${config.id}`,
{ achievement_id: config.id }
);
// Enviar notificación
await this.sendAchievementNotification(userId, achievement);
return achievement;
}
/**
* Obtiene achievements del usuario
*/
async getUserAchievements(userId: string): Promise<Achievement[]> {
return await db.user_achievements.findMany({
where: { user_id: userId },
orderBy: { earned_at: 'desc' }
});
}
/**
* Obtiene achievements disponibles para desbloquear
*/
async getAvailableAchievements(userId: string): Promise<AvailableAchievement[]> {
const earned = await this.getUserAchievements(userId);
const earnedIds = earned.map(a => a.achievement_id);
const available: AvailableAchievement[] = [];
for (const [key, config] of Object.entries(ACHIEVEMENTS)) {
if (earnedIds.includes(config.id)) continue;
const progress = await this.calculateAchievementProgress(
userId,
config
);
available.push({
...config,
progress_percentage: progress.percentage,
progress_current: progress.current,
progress_required: progress.required,
progress_description: progress.description
});
}
return available.sort((a, b) => b.progress_percentage - a.progress_percentage);
}
/**
* Calcula progreso hacia un achievement
*/
private async calculateAchievementProgress(
userId: string,
config: AchievementConfig
): Promise<AchievementProgress> {
switch (config.id) {
case 'first_course':
case 'complete_5_courses': {
const count = await this.getCompletedCoursesCount(userId);
const required = config.id === 'first_course' ? 1 : 5;
return {
current: count,
required: required,
percentage: Math.min((count / required) * 100, 100),
description: `${count}/${required} cursos completados`
};
}
case 'perfect_10_quizzes': {
const count = await this.getPerfectQuizzesCount(userId);
return {
current: count,
required: 10,
percentage: Math.min((count / 10) * 100, 100),
description: `${count}/10 quizzes perfectos`
};
}
case 'streak_7':
case 'streak_30':
case 'streak_100': {
const profile = await this.getUserProfile(userId);
const required = config.id === 'streak_7' ? 7 :
config.id === 'streak_30' ? 30 : 100;
return {
current: profile.current_streak_days,
required: required,
percentage: Math.min((profile.current_streak_days / required) * 100, 100),
description: `${profile.current_streak_days}/${required} días de racha`
};
}
default:
return {
current: 0,
required: 1,
percentage: 0,
description: 'Progreso no disponible'
};
}
}
private async getCompletedCoursesCount(userId: string): Promise<number> {
return await db.enrollments.count({
where: {
user_id: userId,
status: 'completed'
}
});
}
private async getPerfectQuizzesCount(userId: string): Promise<number> {
return await db.quiz_attempts.count({
where: {
user_id: userId,
is_completed: true,
score_percentage: 100
}
});
}
private async getUserProfile(userId: string): Promise<GamificationProfile> {
return await db.user_gamification_profile.findUniqueOrThrow({
where: { user_id: userId }
});
}
private async sendAchievementNotification(
userId: string,
achievement: Achievement
): Promise<void> {
// TODO: Implementar sistema de notificaciones
}
}
interface AchievementEvent {
type: 'course_completed' | 'quiz_completed' | 'daily_activity' | 'level_up';
metadata?: any;
}
interface AchievementConfig {
id: string;
title: string;
description: string;
type: string;
badge_icon: string;
xp_bonus: number;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
}
interface Achievement {
id: string;
user_id: string;
achievement_id: string;
title: string;
description: string;
badge_icon_url: string;
xp_bonus: number;
rarity: string;
earned_at: Date;
}
interface AvailableAchievement extends AchievementConfig {
progress_percentage: number;
progress_current: number;
progress_required: number;
progress_description: string;
}
interface AchievementProgress {
current: number;
required: number;
percentage: number;
description: string;
}
3. SISTEMA DE RACHAS (STREAKS)
Streak Manager
// services/gamification/streak-manager.service.ts
export class StreakManagerService {
/**
* Actualiza la racha del usuario
*/
async updateStreak(userId: string): Promise<StreakUpdate> {
const profile = await this.getUserProfile(userId);
const today = this.getToday();
const lastActivity = this.getDay(profile.last_activity_date);
let currentStreak = profile.current_streak_days;
let longestStreak = profile.longest_streak_days;
let streakBroken = false;
let streakExtended = false;
// Calcular diferencia en días
const daysDiff = this.getDaysDifference(lastActivity, today);
if (daysDiff === 0) {
// Mismo día, no cambios en racha
return {
current_streak: currentStreak,
longest_streak: longestStreak,
streak_extended: false,
streak_broken: false,
xp_earned: 0
};
} else if (daysDiff === 1) {
// Día consecutivo, extender racha
currentStreak += 1;
streakExtended = true;
if (currentStreak > longestStreak) {
longestStreak = currentStreak;
}
} else {
// Racha rota
streakBroken = true;
currentStreak = 1; // Reiniciar racha
}
// Actualizar perfil
await db.user_gamification_profile.update(userId, {
current_streak_days: currentStreak,
longest_streak_days: longestStreak,
last_activity_date: new Date()
});
// Otorgar XP por racha
let xpEarned = 0;
if (streakExtended) {
xpEarned = await this.awardStreakXP(userId, currentStreak);
}
// Verificar logros de racha
await this.checkStreakAchievements(userId, currentStreak, streakExtended);
return {
current_streak: currentStreak,
longest_streak: longestStreak,
streak_extended: streakExtended,
streak_broken: streakBroken,
xp_earned: xpEarned
};
}
/**
* Otorga XP por mantener racha
*/
private async awardStreakXP(
userId: string,
streakDays: number
): Promise<number> {
const xpManager = new XPManagerService();
// XP base por día
let xp = XP_SOURCES.DAILY_LOGIN;
// Bonus por milestones de racha
if (streakDays === 7) {
xp += XP_SOURCES.STREAK_7_DAYS;
} else if (streakDays === 30) {
xp += XP_SOURCES.STREAK_30_DAYS;
} else if (streakDays === 100) {
xp += XP_SOURCES.STREAK_100_DAYS;
}
await xpManager.awardXP(
userId,
xp,
'daily_streak',
{ streak_days: streakDays }
);
return xp;
}
/**
* Verifica achievements de racha
*/
private async checkStreakAchievements(
userId: string,
streakDays: number,
streakExtended: boolean
): Promise<void> {
if (!streakExtended) return;
const achievementManager = new AchievementManagerService();
await achievementManager.checkAndAwardAchievements(userId, {
type: 'daily_activity',
metadata: { current_streak: streakDays }
});
}
/**
* Obtiene estadísticas de racha del usuario
*/
async getStreakStats(userId: string): Promise<StreakStats> {
const profile = await this.getUserProfile(userId);
return {
current_streak: profile.current_streak_days,
longest_streak: profile.longest_streak_days,
last_activity: profile.last_activity_date,
next_milestone: this.getNextMilestone(profile.current_streak_days),
days_to_milestone: this.getDaysToMilestone(profile.current_streak_days)
};
}
private getNextMilestone(currentStreak: number): number {
const milestones = [7, 30, 100, 365];
return milestones.find(m => m > currentStreak) || milestones[milestones.length - 1];
}
private getDaysToMilestone(currentStreak: number): number {
const nextMilestone = this.getNextMilestone(currentStreak);
return nextMilestone - currentStreak;
}
private getToday(): string {
return new Date().toISOString().split('T')[0];
}
private getDay(date: Date): string {
return new Date(date).toISOString().split('T')[0];
}
private getDaysDifference(date1: string, date2: string): number {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d2.getTime() - d1.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
private async getUserProfile(userId: string): Promise<GamificationProfile> {
return await db.user_gamification_profile.findUniqueOrThrow({
where: { user_id: userId }
});
}
}
interface StreakUpdate {
current_streak: number;
longest_streak: number;
streak_extended: boolean;
streak_broken: boolean;
xp_earned: number;
}
interface StreakStats {
current_streak: number;
longest_streak: number;
last_activity: Date;
next_milestone: number;
days_to_milestone: number;
}
4. LEADERBOARD (TABLA DE CLASIFICACIÓN)
Leaderboard Manager
// services/gamification/leaderboard-manager.service.ts
export class LeaderboardManagerService {
/**
* Obtiene el leaderboard global
*/
async getGlobalLeaderboard(
period: 'all_time' | 'month' | 'week' = 'all_time',
limit: number = 100
): Promise<LeaderboardEntry[]> {
// Usar cache de Redis si está disponible
const cacheKey = `leaderboard:${period}:${limit}`;
const cached = await this.getCached(cacheKey);
if (cached) {
return cached;
}
// Calcular leaderboard
const leaderboard = await this.calculateLeaderboard(period, limit);
// Cachear por 5 minutos
await this.setCached(cacheKey, leaderboard, 300);
return leaderboard;
}
/**
* Obtiene posición del usuario en el leaderboard
*/
async getUserPosition(
userId: string,
period: 'all_time' | 'month' | 'week' = 'all_time'
): Promise<UserLeaderboardPosition> {
const leaderboard = await this.getGlobalLeaderboard(period, 10000);
const position = leaderboard.findIndex(entry => entry.user_id === userId);
if (position === -1) {
return {
rank: null,
total_users: leaderboard.length,
percentile: 100
};
}
const percentile = ((leaderboard.length - position) / leaderboard.length) * 100;
return {
rank: position + 1,
total_users: leaderboard.length,
percentile: Math.round(percentile)
};
}
/**
* Obtiene usuarios cercanos en el ranking
*/
async getNearbyUsers(
userId: string,
radius: number = 5
): Promise<LeaderboardEntry[]> {
const leaderboard = await this.getGlobalLeaderboard('all_time', 10000);
const userIndex = leaderboard.findIndex(entry => entry.user_id === userId);
if (userIndex === -1) return [];
const start = Math.max(0, userIndex - radius);
const end = Math.min(leaderboard.length, userIndex + radius + 1);
return leaderboard.slice(start, end);
}
/**
* Calcula el leaderboard
*/
private async calculateLeaderboard(
period: string,
limit: number
): Promise<LeaderboardEntry[]> {
let dateFilter = {};
if (period === 'week') {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
dateFilter = { gte: weekAgo };
} else if (period === 'month') {
const monthAgo = new Date();
monthAgo.setMonth(monthAgo.getMonth() - 1);
dateFilter = { gte: monthAgo };
}
// Query optimizado con aggregation
const results = await db.user_gamification_profile.findMany({
where: period !== 'all_time' ? {
last_activity_date: dateFilter
} : undefined,
orderBy: {
total_xp: 'desc'
},
take: limit,
include: {
user: {
select: {
id: true,
name: true,
avatar_url: true
}
},
_count: {
select: {
enrollments: {
where: { status: 'completed' }
},
achievements: true
}
}
}
});
return results.map((result, index) => ({
rank: index + 1,
user_id: result.user_id,
user_name: result.user.name,
avatar_url: result.user.avatar_url,
total_xp: result.total_xp,
current_level: result.current_level,
courses_completed: result._count.enrollments,
achievements_count: result._count.achievements,
current_streak: result.current_streak_days
}));
}
private async getCached(key: string): Promise<any> {
// TODO: Implementar con Redis
return null;
}
private async setCached(key: string, value: any, ttl: number): Promise<void> {
// TODO: Implementar con Redis
}
}
interface LeaderboardEntry {
rank: number;
user_id: string;
user_name: string;
avatar_url: string | null;
total_xp: number;
current_level: number;
courses_completed: number;
achievements_count: number;
current_streak: number;
}
interface UserLeaderboardPosition {
rank: number | null;
total_users: number;
percentile: number;
}
Interfaces/Tipos
Ver secciones anteriores (incluidas en el código)
Configuración
# Gamification
GAMIFICATION_ENABLED=true
GAMIFICATION_XP_MULTIPLIER=1.0
GAMIFICATION_WEEKEND_BONUS=1.5
GAMIFICATION_PREMIUM_BONUS=1.5
# Leaderboard
LEADERBOARD_CACHE_TTL=300
LEADERBOARD_MAX_SIZE=1000
# Notifications
NOTIFY_LEVEL_UP=true
NOTIFY_ACHIEVEMENT=true
NOTIFY_STREAK_MILESTONE=true
Dependencias
{
"dependencies": {
"redis": "^4.6.12"
}
}
Consideraciones de Seguridad
- Validar todas las fuentes de XP en el backend
- Prevenir gaming del sistema (anti-cheat)
- Rate limiting en endpoints de gamificación
- Cachear leaderboards para prevenir queries costosos
Testing
describe('XPManagerService', () => {
it('should calculate correct level from XP', () => {
const xpManager = new XPManagerService();
expect(xpManager.calculateLevel(100)).toBe(1);
expect(xpManager.calculateLevel(400)).toBe(2);
expect(xpManager.calculateLevel(10000)).toBe(10);
});
it('should apply weekend multiplier', async () => {
// Mock isWeekend to return true
// Test XP award
});
});
Fin de Especificación ET-EDU-006