# ET-GAM-002: Implementación del Sistema de Comodines ## 📋 Metadata | Campo | Valor | |-------|-------| | **ID** | ET-GAM-002 | | **Módulo** | 02 - Gamificación | | **Título** | Implementación del Sistema de Comodines (Power-ups) | | **Prioridad** | Alta | | **Estado** | ✅ Implementado | | **Versión** | 2.3.0 | | **Fecha Creación** | 2025-11-07 | | **Última Actualización** | 2025-11-28 | | **Sistema Actual** | [docs/sistema-recompensas/](../../../sistema-recompensas/) v2.3.0 | | **Autor** | Database Team | | **Reviewers** | Backend Lead, Frontend Lead, QA Lead | --- ## 🔗 Referencias ### Requerimiento Funcional 📘 **Documento RF:** - [RF-GAM-002: Sistema de Comodines (Power-ups)](../../01-requerimientos/02-gamificacion/RF-GAM-002-comodines.md) ### Implementación DDL 🗄️ **ENUM:** - `gamification_system.comodin_type` - `apps/database/ddl/00-prerequisites.sql:55-58` 🗄️ **Tablas:** - `gamification_system.comodines_inventory` - Inventario por usuario - `gamification_system.comodin_usage_log` - Log de usos - `gamification_system.comodin_usage_tracking` - Tracking de límites por ejercicio 🗄️ **Funciones:** - `purchase_comodin()` - Comprar comodín con ML Coins - `use_comodin()` - Usar comodín en ejercicio - `get_comodin_inventory()` - Obtener inventario completo --- ## 🏗️ Arquitectura ### Diagrama de Capas ``` ┌────────────────────────────────────────────────────┐ │ FRONTEND (React) │ │ - ComodinShop (tienda) │ │ - ComodinButton (botones en ejercicio) │ │ - ComodinInventory (inventario del usuario) │ │ - HintDisplay (mostrar pistas) │ └─────────────────┬──────────────────────────────────┘ │ REST API ┌─────────────────▼──────────────────────────────────┐ │ BACKEND (NestJS) │ │ - ComodinService │ │ · purchaseComodin() │ │ · useComodin() │ │ · getInventory() │ │ - ComodinController │ │ - DTOs: PurchaseComodinDto, UseComodinDto │ └─────────────────┬──────────────────────────────────┘ │ SQL Queries + Transactions ┌─────────────────▼──────────────────────────────────┐ │ DATABASE (PostgreSQL) │ │ - comodines_inventory (inventario) │ │ - comodin_usage_log (historial) │ │ - comodin_usage_tracking (límites por ejercicio) │ │ - purchase_comodin() (transacción atómica) │ │ - use_comodin() (validaciones + logging) │ └────────────────────────────────────────────────────┘ ``` ### Flujo de Compra ``` Usuario en ComodinShop ↓ Selecciona tipo (pistas, visión, segunda) ↓ Click "Comprar" ↓ Frontend → POST /comodines/purchase ↓ ┌────────────────────────────────────┐ │ ComodinService.purchaseComodin() │ │ - Validar balance ML Coins │ │ - Llamar purchase_comodin() │ └────────────┬───────────────────────┘ ↓ ┌────────────────────────────────────┐ │ purchase_comodin() SQL │ │ BEGIN TRANSACTION │ │ - Verificar balance │ │ - Deducir ML Coins │ │ - Incrementar inventario │ │ - Registrar en audit_log │ │ COMMIT │ └────────────┬───────────────────────┘ ↓ Frontend muestra confirmación + Actualiza inventario UI ``` ### Flujo de Uso en Ejercicio ``` Usuario intenta ejercicio ↓ Click "Usar Pista" ↓ Frontend → POST /comodines/use ↓ ┌────────────────────────────────────┐ │ ComodinService.useComodin() │ │ - Validar inventario │ │ - Validar límites por ejercicio │ │ - Llamar use_comodin() │ │ - Generar contenido (ej: pista #1) │ └────────────┬───────────────────────┘ ↓ ┌────────────────────────────────────┐ │ use_comodin() SQL │ │ BEGIN TRANSACTION │ │ - Verificar disponibilidad │ │ - Decrementar inventario │ │ - Registrar uso en log │ │ - Actualizar tracking │ │ COMMIT │ └────────────┬───────────────────────┘ ↓ Frontend muestra efecto del comodín (pista, highlight, reset attempt) ``` --- ## 💾 Implementación de Base de Datos ### 1. ENUM: comodin_type **Ubicación:** `apps/database/ddl/00-prerequisites.sql:55-58` ```sql -- Comodin Types (Power-ups) CREATE TYPE gamification_system.comodin_type AS ENUM ( 'pistas', -- Hints (hasta 3 por ejercicio) 'vision_lectora', -- Reading Vision (1 por ejercicio) 'segunda_oportunidad' -- Second Chance (1 por ejercicio) ); COMMENT ON TYPE gamification_system.comodin_type IS ' Tipos de comodines (power-ups): - pistas: Revelan pistas progresivas (máx 3 por ejercicio) - vision_lectora: Resaltan oraciones clave en textos largos (1 por ejercicio) - segunda_oportunidad: Reintentar sin penalización (1 por ejercicio) '; ``` ### 2. Tabla: comodines_inventory **Ubicación:** `apps/database/ddl/schemas/gamification_system/tables/comodines_inventory.sql` ```sql CREATE TABLE IF NOT EXISTS gamification_system.comodines_inventory ( -- No hay PK individual, la combinación user_id + comodin_type es única user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, comodin_type gamification_system.comodin_type NOT NULL, -- Cantidades quantity INTEGER NOT NULL DEFAULT 0 CHECK (quantity >= 0), total_purchased INTEGER NOT NULL DEFAULT 0 CHECK (total_purchased >= 0), total_used INTEGER NOT NULL DEFAULT 0 CHECK (total_used >= 0), -- Timestamps last_purchased_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ, -- Metadata created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), -- Constraint PRIMARY KEY (user_id, comodin_type) ); -- Índices CREATE INDEX idx_comodines_inventory_user ON gamification_system.comodines_inventory(user_id); -- Trigger para actualizar updated_at CREATE TRIGGER trg_comodines_inventory_updated_at BEFORE UPDATE ON gamification_system.comodines_inventory FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); -- Comentarios COMMENT ON TABLE gamification_system.comodines_inventory IS 'Inventario de comodines disponibles por usuario'; COMMENT ON COLUMN gamification_system.comodines_inventory.quantity IS 'Cantidad disponible actual'; COMMENT ON COLUMN gamification_system.comodines_inventory.total_purchased IS 'Total comprado históricamente'; COMMENT ON COLUMN gamification_system.comodines_inventory.total_used IS 'Total usado históricamente'; -- Constraint adicional: máximo 99 unidades por tipo ALTER TABLE gamification_system.comodines_inventory ADD CONSTRAINT chk_max_inventory CHECK (quantity <= 99); ``` ### 3. Tabla: comodin_usage_log **Ubicación:** `apps/database/ddl/schemas/gamification_system/tables/comodin_usage_log.sql` ```sql CREATE TABLE IF NOT EXISTS gamification_system.comodin_usage_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, exercise_id UUID NOT NULL REFERENCES educational_content.exercises(id) ON DELETE CASCADE, attempt_id UUID REFERENCES progress_tracking.exercise_attempts(id) ON DELETE SET NULL, -- Tipo de comodín usado comodin_type gamification_system.comodin_type NOT NULL, -- Contexto hint_number INTEGER, -- Si es pista, cuál número (1, 2, 3) was_successful BOOLEAN, -- Si el intento fue exitoso después de usar comodín exercise_difficulty VARCHAR(20), -- Dificultad del ejercicio (para análisis) -- Timestamp used_at TIMESTAMPTZ DEFAULT NOW() ); -- Índices CREATE INDEX idx_comodin_usage_log_user ON gamification_system.comodin_usage_log(user_id); CREATE INDEX idx_comodin_usage_log_exercise ON gamification_system.comodin_usage_log(exercise_id); CREATE INDEX idx_comodin_usage_log_used_at ON gamification_system.comodin_usage_log(used_at DESC); CREATE INDEX idx_comodin_usage_log_type ON gamification_system.comodin_usage_log(comodin_type); -- Comentarios COMMENT ON TABLE gamification_system.comodin_usage_log IS 'Log inmutable de cada uso de comodín'; COMMENT ON COLUMN gamification_system.comodin_usage_log.hint_number IS 'Para pistas: 1, 2 o 3'; COMMENT ON COLUMN gamification_system.comodin_usage_log.was_successful IS 'Si el ejercicio se completó exitosamente después de usar comodín'; ``` ### 4. Tabla: comodin_usage_tracking **Ubicación:** `apps/database/ddl/schemas/gamification_system/tables/comodin_usage_tracking.sql` ```sql CREATE TABLE IF NOT EXISTS gamification_system.comodin_usage_tracking ( user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, exercise_id UUID NOT NULL REFERENCES educational_content.exercises(id) ON DELETE CASCADE, attempt_id UUID NOT NULL REFERENCES progress_tracking.exercise_attempts(id) ON DELETE CASCADE, -- Contadores por tipo pistas_used INTEGER NOT NULL DEFAULT 0 CHECK (pistas_used >= 0 AND pistas_used <= 3), vision_lectora_used BOOLEAN NOT NULL DEFAULT false, segunda_oportunidad_used BOOLEAN NOT NULL DEFAULT false, -- Timestamps created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), -- PK compuesta PRIMARY KEY (user_id, exercise_id, attempt_id) ); -- Índices CREATE INDEX idx_comodin_tracking_user_exercise ON gamification_system.comodin_usage_tracking(user_id, exercise_id); -- Comentarios COMMENT ON TABLE gamification_system.comodin_usage_tracking IS 'Tracking de límites de comodines por ejercicio y attempt'; COMMENT ON COLUMN gamification_system.comodin_usage_tracking.pistas_used IS 'Contador de pistas usadas (máx 3)'; ``` ### 5. Función: purchase_comodin **Ubicación:** `apps/database/ddl/schemas/gamification_system/functions/purchase_comodin.sql` ```sql CREATE OR REPLACE FUNCTION gamification_system.purchase_comodin( p_user_id UUID, p_comodin_type gamification_system.comodin_type, p_quantity INTEGER DEFAULT 1 ) RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE v_cost_per_unit INTEGER; v_total_cost INTEGER; v_current_balance INTEGER; v_current_quantity INTEGER; v_result JSONB; BEGIN -- 1. Determinar costo por unidad CASE p_comodin_type WHEN 'pistas' THEN v_cost_per_unit := 15; WHEN 'vision_lectora' THEN v_cost_per_unit := 25; WHEN 'segunda_oportunidad' THEN v_cost_per_unit := 40; ELSE RAISE EXCEPTION 'Invalid comodin type: %', p_comodin_type; END CASE; v_total_cost := v_cost_per_unit * p_quantity; -- 2. Obtener balance actual SELECT ml_coins INTO v_current_balance FROM gamification_system.user_stats WHERE user_id = p_user_id; IF v_current_balance IS NULL THEN RAISE EXCEPTION 'User stats not found for user %', p_user_id; END IF; -- 3. Validar balance suficiente IF v_current_balance < v_total_cost THEN RAISE EXCEPTION 'Insufficient ML Coins. Required: %, Available: %', v_total_cost, v_current_balance; END IF; -- 4. Obtener cantidad actual en inventario SELECT quantity INTO v_current_quantity FROM gamification_system.comodines_inventory WHERE user_id = p_user_id AND comodin_type = p_comodin_type; -- Si no existe registro, crear uno IF v_current_quantity IS NULL THEN INSERT INTO gamification_system.comodines_inventory ( user_id, comodin_type, quantity, total_purchased, last_purchased_at ) VALUES ( p_user_id, p_comodin_type, p_quantity, p_quantity, NOW() ); v_current_quantity := 0; ELSE -- 5. Validar que no exceda límite de inventario (99) IF v_current_quantity + p_quantity > 99 THEN RAISE EXCEPTION 'Max inventory limit reached. Current: %, Trying to add: %', v_current_quantity, p_quantity; END IF; -- 6. Actualizar inventario UPDATE gamification_system.comodines_inventory SET quantity = quantity + p_quantity, total_purchased = total_purchased + p_quantity, last_purchased_at = NOW(), updated_at = NOW() WHERE user_id = p_user_id AND comodin_type = p_comodin_type; END IF; -- 7. Deducir ML Coins UPDATE gamification_system.user_stats SET ml_coins = ml_coins - v_total_cost, updated_at = NOW() WHERE user_id = p_user_id; -- 8. Registrar en audit log INSERT INTO audit_logging.audit_logs ( user_id, action, resource_type, resource_id, details, severity ) VALUES ( p_user_id, 'comodin_purchased', 'comodin', p_comodin_type::TEXT, jsonb_build_object( 'quantity', p_quantity, 'cost_per_unit', v_cost_per_unit, 'total_cost', v_total_cost, 'balance_before', v_current_balance, 'balance_after', v_current_balance - v_total_cost ), 'info' ); -- 9. Construir resultado v_result := jsonb_build_object( 'success', true, 'comodin_type', p_comodin_type, 'quantity_purchased', p_quantity, 'total_cost', v_total_cost, 'new_balance', v_current_balance - v_total_cost, 'new_quantity', v_current_quantity + p_quantity ); RAISE NOTICE 'User % purchased % x %', p_user_id, p_quantity, p_comodin_type; RETURN v_result; END; $$; COMMENT ON FUNCTION gamification_system.purchase_comodin IS 'Comprar comodín con ML Coins (transacción atómica)'; ``` ### 6. Función: use_comodin **Ubicación:** `apps/database/ddl/schemas/gamification_system/functions/use_comodin.sql` ```sql CREATE OR REPLACE FUNCTION gamification_system.use_comodin( p_user_id UUID, p_exercise_id UUID, p_attempt_id UUID, p_comodin_type gamification_system.comodin_type ) RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE v_current_quantity INTEGER; v_pistas_used INTEGER := 0; v_vision_used BOOLEAN := false; v_segunda_used BOOLEAN := false; v_hint_number INTEGER := NULL; v_exercise_difficulty VARCHAR(20); v_is_exam BOOLEAN; v_result JSONB; BEGIN -- 1. Validar que ejercicio no sea examen SELECT difficulty, is_exam INTO v_exercise_difficulty, v_is_exam FROM educational_content.exercises WHERE id = p_exercise_id; IF v_is_exam THEN RAISE EXCEPTION 'Power-ups are not allowed in exam mode'; END IF; -- 2. Verificar inventario SELECT quantity INTO v_current_quantity FROM gamification_system.comodines_inventory WHERE user_id = p_user_id AND comodin_type = p_comodin_type; IF v_current_quantity IS NULL OR v_current_quantity <= 0 THEN RAISE EXCEPTION 'No % available in inventory', p_comodin_type; END IF; -- 3. Obtener tracking actual para este ejercicio + attempt SELECT pistas_used, vision_lectora_used, segunda_oportunidad_used INTO v_pistas_used, v_vision_used, v_segunda_used FROM gamification_system.comodin_usage_tracking WHERE user_id = p_user_id AND exercise_id = p_exercise_id AND attempt_id = p_attempt_id; -- Si no existe, crear registro IF NOT FOUND THEN INSERT INTO gamification_system.comodin_usage_tracking ( user_id, exercise_id, attempt_id, pistas_used, vision_lectora_used, segunda_oportunidad_used ) VALUES ( p_user_id, p_exercise_id, p_attempt_id, 0, false, false ); v_pistas_used := 0; v_vision_used := false; v_segunda_used := false; END IF; -- 4. Validar límites por tipo IF p_comodin_type = 'pistas' THEN IF v_pistas_used >= 3 THEN RAISE EXCEPTION 'Max hints reached for this exercise (3)'; END IF; v_hint_number := v_pistas_used + 1; ELSIF p_comodin_type = 'vision_lectora' THEN IF v_vision_used THEN RAISE EXCEPTION 'Reading Vision already used in this exercise'; END IF; ELSIF p_comodin_type = 'segunda_oportunidad' THEN IF v_segunda_used THEN RAISE EXCEPTION 'Second Chance already used in this exercise'; END IF; END IF; -- 5. Decrementar inventario UPDATE gamification_system.comodines_inventory SET quantity = quantity - 1, total_used = total_used + 1, last_used_at = NOW(), updated_at = NOW() WHERE user_id = p_user_id AND comodin_type = p_comodin_type; -- 6. Actualizar tracking IF p_comodin_type = 'pistas' THEN UPDATE gamification_system.comodin_usage_tracking SET pistas_used = pistas_used + 1, updated_at = NOW() WHERE user_id = p_user_id AND exercise_id = p_exercise_id AND attempt_id = p_attempt_id; ELSIF p_comodin_type = 'vision_lectora' THEN UPDATE gamification_system.comodin_usage_tracking SET vision_lectora_used = true, updated_at = NOW() WHERE user_id = p_user_id AND exercise_id = p_exercise_id AND attempt_id = p_attempt_id; ELSIF p_comodin_type = 'segunda_oportunidad' THEN UPDATE gamification_system.comodin_usage_tracking SET segunda_oportunidad_used = true, updated_at = NOW() WHERE user_id = p_user_id AND exercise_id = p_exercise_id AND attempt_id = p_attempt_id; END IF; -- 7. Registrar en log INSERT INTO gamification_system.comodin_usage_log ( user_id, exercise_id, attempt_id, comodin_type, hint_number, exercise_difficulty, used_at ) VALUES ( p_user_id, p_exercise_id, p_attempt_id, p_comodin_type, v_hint_number, v_exercise_difficulty, NOW() ); -- 8. Construir resultado v_result := jsonb_build_object( 'success', true, 'comodin_type', p_comodin_type, 'hint_number', v_hint_number, 'remaining_quantity', v_current_quantity - 1 ); RAISE NOTICE 'User % used % in exercise %', p_user_id, p_comodin_type, p_exercise_id; RETURN v_result; END; $$; COMMENT ON FUNCTION gamification_system.use_comodin IS 'Usar comodín en ejercicio (validaciones + logging)'; ``` ### 7. Función: get_comodin_inventory **Ubicación:** `apps/database/ddl/schemas/gamification_system/functions/get_comodin_inventory.sql` ```sql CREATE OR REPLACE FUNCTION gamification_system.get_comodin_inventory( p_user_id UUID ) RETURNS TABLE ( comodin_type gamification_system.comodin_type, quantity INTEGER, total_purchased INTEGER, total_used INTEGER, last_purchased_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ ) LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN RETURN QUERY SELECT ci.comodin_type, ci.quantity, ci.total_purchased, ci.total_used, ci.last_purchased_at, ci.last_used_at FROM gamification_system.comodines_inventory ci WHERE ci.user_id = p_user_id ORDER BY CASE ci.comodin_type WHEN 'pistas' THEN 1 WHEN 'vision_lectora' THEN 2 WHEN 'segunda_oportunidad' THEN 3 END; END; $$; COMMENT ON FUNCTION gamification_system.get_comodin_inventory IS 'Obtener inventario completo de comodines del usuario'; ``` --- ## 🔧 Implementación Backend (NestJS) ### 1. Enum TypeScript **Ubicación:** `apps/backend/src/gamification/enums/comodin-type.enum.ts` ```typescript export enum ComodinTypeEnum { PISTAS = 'pistas', VISION_LECTORA = 'vision_lectora', SEGUNDA_OPORTUNIDAD = 'segunda_oportunidad', } export const COMODIN_COSTS: Record = { [ComodinTypeEnum.PISTAS]: 15, [ComodinTypeEnum.VISION_LECTORA]: 25, [ComodinTypeEnum.SEGUNDA_OPORTUNIDAD]: 40, }; export const COMODIN_LIMITS: Record = { [ComodinTypeEnum.PISTAS]: 3, [ComodinTypeEnum.VISION_LECTORA]: 1, [ComodinTypeEnum.SEGUNDA_OPORTUNIDAD]: 1, }; ``` ### 2. Entities **Ubicación:** `apps/backend/src/gamification/entities/comodin-inventory.entity.ts` ```typescript import { Entity, Column, PrimaryColumn } from 'typeorm'; import { ComodinTypeEnum } from '../enums/comodin-type.enum'; @Entity({ schema: 'gamification_system', name: 'comodines_inventory' }) export class ComodinInventory { @PrimaryColumn({ type: 'uuid', name: 'user_id' }) userId: string; @PrimaryColumn({ type: 'enum', enum: ComodinTypeEnum, name: 'comodin_type' }) comodinType: ComodinTypeEnum; @Column({ type: 'integer', default: 0 }) quantity: number; @Column({ type: 'integer', default: 0, name: 'total_purchased' }) totalPurchased: number; @Column({ type: 'integer', default: 0, name: 'total_used' }) totalUsed: number; @Column({ type: 'timestamptz', nullable: true, name: 'last_purchased_at' }) lastPurchasedAt?: Date; @Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' }) lastUsedAt?: Date; @Column({ type: 'timestamptz', default: () => 'NOW()', name: 'created_at' }) createdAt: Date; @Column({ type: 'timestamptz', default: () => 'NOW()', name: 'updated_at' }) updatedAt: Date; } ``` ### 3. DTOs **Ubicación:** `apps/backend/src/gamification/dto/comodin.dto.ts` ```typescript import { IsEnum, IsInt, Min, Max, IsUUID, IsOptional } from 'class-validator'; import { ComodinTypeEnum } from '../enums/comodin-type.enum'; export class PurchaseComodinDto { @IsEnum(ComodinTypeEnum) comodinType: ComodinTypeEnum; @IsInt() @Min(1) @Max(10) // Máximo 10 unidades por compra quantity: number; } export class UseComodinDto { @IsUUID() exerciseId: string; @IsUUID() attemptId: string; @IsEnum(ComodinTypeEnum) comodinType: ComodinTypeEnum; } export class ComodinInventoryDto { comodinType: ComodinTypeEnum; quantity: number; totalPurchased: number; totalUsed: number; lastPurchasedAt?: Date; lastUsedAt?: Date; cost: number; // Costo unitario } ``` ### 4. ComodinService **Ubicación:** `apps/backend/src/gamification/services/comodin.service.ts` ```typescript import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ComodinInventory } from '../entities/comodin-inventory.entity'; import { ComodinTypeEnum, COMODIN_COSTS } from '../enums/comodin-type.enum'; import { PurchaseComodinDto, UseComodinDto, ComodinInventoryDto } from '../dto/comodin.dto'; @Injectable() export class ComodinService { constructor( @InjectRepository(ComodinInventory) private comodinInventoryRepo: Repository, ) {} /** * Comprar comodín con ML Coins */ async purchase(userId: string, dto: PurchaseComodinDto): Promise<{ success: boolean; comodinType: ComodinTypeEnum; quantityPurchased: number; totalCost: number; newBalance: number; newQuantity: number; }> { const result = await this.comodinInventoryRepo.query( 'SELECT * FROM gamification_system.purchase_comodin($1, $2, $3)', [userId, dto.comodinType, dto.quantity] ); return result[0].purchase_comodin; } /** * Usar comodín en ejercicio */ async use(userId: string, dto: UseComodinDto): Promise<{ success: boolean; comodinType: ComodinTypeEnum; hintNumber?: number; remainingQuantity: number; effect?: any; // Contenido específico del comodín }> { const result = await this.comodinInventoryRepo.query( 'SELECT * FROM gamification_system.use_comodin($1, $2, $3, $4)', [userId, dto.exerciseId, dto.attemptId, dto.comodinType] ); const usageResult = result[0].use_comodin; // Generar efecto según tipo let effect = null; if (dto.comodinType === ComodinTypeEnum.PISTAS) { effect = await this.generateHint(dto.exerciseId, usageResult.hint_number); } else if (dto.comodinType === ComodinTypeEnum.VISION_LECTORA) { effect = await this.generateReadingVision(dto.exerciseId); } return { ...usageResult, effect, }; } /** * Obtener inventario completo */ async getInventory(userId: string): Promise { const result = await this.comodinInventoryRepo.query( 'SELECT * FROM gamification_system.get_comodin_inventory($1)', [userId] ); return result.map((row) => ({ comodinType: row.comodin_type, quantity: row.quantity, totalPurchased: row.total_purchased, totalUsed: row.total_used, lastPurchasedAt: row.last_purchased_at, lastUsedAt: row.last_used_at, cost: COMODIN_COSTS[row.comodin_type], })); } /** * Verificar disponibilidad de comodín */ async hasAvailable(userId: string, comodinType: ComodinTypeEnum): Promise { const inventory = await this.comodinInventoryRepo.findOne({ where: { userId, comodinType }, }); return inventory ? inventory.quantity > 0 : false; } /** * Generar contenido de pista */ private async generateHint(exerciseId: string, hintNumber: number): Promise { // Obtener ejercicio const exercise = await this.comodinInventoryRepo.query( 'SELECT hints FROM educational_content.exercises WHERE id = $1', [exerciseId] ); if (!exercise[0] || !exercise[0].hints) { throw new BadRequestException('No hints available for this exercise'); } const hints = exercise[0].hints; // JSONB array: ["hint1", "hint2", "hint3"] if (hints.length < hintNumber) { throw new BadRequestException(`Hint ${hintNumber} not available`); } return hints[hintNumber - 1]; } /** * Generar efecto de Visión Lectora */ private async generateReadingVision(exerciseId: string): Promise<{ highlightedSentences: number[]; // Índices de oraciones a resaltar }> { // Obtener ejercicio y su contenido const exercise = await this.comodinInventoryRepo.query( 'SELECT content, answer_key FROM educational_content.exercises WHERE id = $1', [exerciseId] ); if (!exercise[0]) { throw new BadRequestException('Exercise not found'); } // Algoritmo simple: resaltar oraciones que contengan palabras clave de la respuesta // En producción, usar NLP más sofisticado const content = exercise[0].content; const answerKey = exercise[0].answer_key; // Simplificación: devolver índices de oraciones relevantes // TODO: Implementar algoritmo de NLP real const highlightedSentences = [1, 3, 5]; // Placeholder return { highlightedSentences }; } } ``` ### 5. Controller **Ubicación:** `apps/backend/src/gamification/controllers/comodin.controller.ts` ```typescript import { Controller, Get, Post, Body, UseGuards, Req } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { ComodinService } from '../services/comodin.service'; import { PurchaseComodinDto, UseComodinDto } from '../dto/comodin.dto'; @Controller('comodines') @UseGuards(JwtAuthGuard) export class ComodinController { constructor(private comodinService: ComodinService) {} /** * POST /comodines/purchase * Comprar comodín con ML Coins */ @Post('purchase') async purchase(@Req() req, @Body() dto: PurchaseComodinDto) { return await this.comodinService.purchase(req.user.id, dto); } /** * POST /comodines/use * Usar comodín en ejercicio */ @Post('use') async use(@Req() req, @Body() dto: UseComodinDto) { return await this.comodinService.use(req.user.id, dto); } /** * GET /comodines/inventory * Obtener inventario del usuario */ @Get('inventory') async getInventory(@Req() req) { return await this.comodinService.getInventory(req.user.id); } } ``` --- ## 🎨 Implementación Frontend (React) ### 1. Component: ComodinShop **Ubicación:** `apps/frontend/src/components/gamification/ComodinShop.tsx` ```typescript import React, { useState, useEffect } from 'react'; import { comodinService } from '../../services/comodin.service'; import { ComodinTypeEnum, COMODIN_COSTS } from '../../types/comodin.types'; import { toast } from 'react-toastify'; export const ComodinShop: React.FC = () => { const [inventory, setInventory] = useState([]); const [mlCoins, setMlCoins] = useState(0); const [loading, setLoading] = useState(false); useEffect(() => { loadData(); }, []); const loadData = async () => { const inv = await comodinService.getInventory(); setInventory(inv); // Obtener ML Coins del usuario const userStats = await comodinService.getUserStats(); setMlCoins(userStats.mlCoins); }; const handlePurchase = async (type: ComodinTypeEnum, quantity: number) => { try { setLoading(true); const result = await comodinService.purchase({ comodinType: type, quantity }); toast.success(`Compraste ${quantity} ${type}!`); setMlCoins(result.newBalance); await loadData(); // Recargar inventario } catch (error) { toast.error(error.response?.data?.message || 'Error al comprar'); } finally { setLoading(false); } }; return (

Tienda de Comodines

{/* Balance */}

Tu balance: {mlCoins} ML Coins

{/* Grid de productos */}
{/* Pistas */}
💡

Pistas

Recibe hasta 3 pistas progresivas por ejercicio

15 ML Coins
{/* Visión Lectora */}
👁️

Visión Lectora

Resalta oraciones clave en textos largos

25 ML Coins
{/* Segunda Oportunidad */}
♻️

Segunda Oportunidad

Reintenta sin perder racha ni XP

40 ML Coins
{/* Inventario actual */}

Tu Inventario

{inventory.map((item) => (

{item.comodinType}

{item.quantity}

Comprados: {item.totalPurchased}

))}
); }; ``` ### 2. Component: ComodinButton (en ejercicio) **Ubicación:** `apps/frontend/src/components/exercises/ComodinButton.tsx` ```typescript import React from 'react'; import { ComodinTypeEnum } from '../../types/comodin.types'; interface ComodinButtonProps { type: ComodinTypeEnum; available: number; onClick: () => void; disabled?: boolean; } const COMODIN_ICONS = { [ComodinTypeEnum.PISTAS]: '💡', [ComodinTypeEnum.VISION_LECTORA]: '👁️', [ComodinTypeEnum.SEGUNDA_OPORTUNIDAD]: '♻️', }; const COMODIN_LABELS = { [ComodinTypeEnum.PISTAS]: 'Usar Pista', [ComodinTypeEnum.VISION_LECTORA]: 'Visión Lectora', [ComodinTypeEnum.SEGUNDA_OPORTUNIDAD]: 'Segunda Oportunidad', }; export const ComodinButton: React.FC = ({ type, available, onClick, disabled }) => { return ( ); }; ``` --- ## 🧪 Testing ### Test Case 1: Compra Exitosa ```typescript test('User can purchase comodin with sufficient ML Coins', async () => { const user = await createUser({ ml_coins: 100 }); const result = await comodinService.purchase(user.id, { comodinType: ComodinTypeEnum.PISTAS, quantity: 3, }); expect(result.success).toBe(true); expect(result.totalCost).toBe(45); // 15 * 3 expect(result.newBalance).toBe(55); // 100 - 45 const inventory = await getComodinInventory(user.id); expect(inventory[ComodinTypeEnum.PISTAS].quantity).toBe(3); }); ``` --- ## 📊 Performance ### Índices Críticos ```sql CREATE INDEX idx_comodines_inventory_user ON gamification_system.comodines_inventory(user_id); CREATE INDEX idx_comodin_usage_log_user ON gamification_system.comodin_usage_log(user_id); ``` --- ## 📅 Historial de Cambios | Versión | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0 | 2025-11-07 | Database Team | Creación del documento | | 1.1 | 2025-11-11 | Database Team | Actualización de costos a valores DDL implementados (pistas: 15, vision_lectora: 25, segunda_oportunidad: 40) | --- **Documento:** `docs/02-especificaciones-tecnicas/02-gamificacion/ET-GAM-002-comodines.md` **Propósito:** Especificación técnica completa del sistema de comodines **Audiencia:** Backend Developers, Frontend Developers, QA Team