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>
1244 lines
34 KiB
Markdown
1244 lines
34 KiB
Markdown
---
|
|
id: "ET-EDU-006"
|
|
title: "Gamification System"
|
|
type: "Specification"
|
|
status: "Done"
|
|
rf_parent: "RF-EDU-006"
|
|
epic: "OQI-002"
|
|
version: "1.0"
|
|
created_date: "2025-12-05"
|
|
updated_date: "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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"redis": "^4.6.12"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Consideraciones de Seguridad
|
|
|
|
1. **Validar todas las fuentes de XP en el backend**
|
|
2. **Prevenir gaming del sistema (anti-cheat)**
|
|
3. **Rate limiting en endpoints de gamificación**
|
|
4. **Cachear leaderboards para prevenir queries costosos**
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
```typescript
|
|
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**
|