workspace-v1/projects/gamilit/docs/01-fase-alcance-inicial/EAI-003-gamificacion/historias-usuario/US-GAM-004-sistema-ayudas.md
Adrian Flores Cortes 967ab360bb Initial commit: Workspace v1 with 3-layer architecture
Structure:
- control-plane/: Registries, SIMCO directives, CI/CD templates
- projects/: Gamilit, ERP-Suite, Trading-Platform, Betting-Analytics
- shared/: Libs catalog, knowledge-base

Key features:
- Centralized port, domain, database, and service registries
- 23 SIMCO directives + 6 fundamental principles
- NEXUS agent profiles with delegation rules
- Validation scripts for workspace integrity
- Dockerfiles for all services
- Path aliases for quick reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 00:35:19 -06:00

8.1 KiB

US-GAM-004: Sistema de ayudas

Épica: EAI-003 - Gamificación Básica Sprint: Mes 1, Semana 3 Story Points: 7 SP Presupuesto: $2,600 MXN Prioridad: Alta (Alcance Inicial) Estado: Completada (Mes 1)


Descripción

Como estudiante, quiero usar ayudas durante actividades difíciles para facilitar mi aprendizaje gastando monedas.

Contexto del Alcance Inicial: 3 tipos de ayudas con costos fijos en monedas. Solo disponibles en actividades de opción múltiple y verdadero/falso.


Criterios de Aceptación

  • CA-01: 3 ayudas: Pista (5 coins), Eliminar opción (10 coins), Tiempo extra (15 coins)
  • CA-02: Botones de ayudas visibles durante actividad
  • CA-03: Se muestra costo en monedas
  • CA-04: Confirmación antes de usar (gastar monedas)
  • CA-05: Ayuda "Pista" muestra hint educativo
  • CA-06: Ayuda "Eliminar opción" tacha 1-2 opciones incorrectas
  • CA-07: Ayuda "Tiempo extra" agrega +30 segundos (si hay timer)
  • CA-08: No se pueden usar si no hay monedas suficientes
  • CA-09: Máximo 1 ayuda de cada tipo por actividad
  • CA-10: Se registra uso de ayudas en analytics

Especificaciones Técnicas

Backend

enum HelpType {
  HINT = 'hint',
  REMOVE_OPTION = 'remove_option',
  EXTRA_TIME = 'extra_time'
}

const HELP_COSTS = {
  [HelpType.HINT]: 5,
  [HelpType.REMOVE_OPTION]: 10,
  [HelpType.EXTRA_TIME]: 15
}

@Entity('help_usage')
class HelpUsage {
  @PrimaryGeneratedColumn('uuid')
  id: string

  @Column()
  userId: string

  @Column()
  activityId: string

  @Column({ type: 'enum', enum: HelpType })
  helpType: HelpType

  @Column({ type: 'int' })
  coinsCost: number

  @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
  usedAt: Date
}

class HelpService {
  async useHelp(userId: string, activityId: string, helpType: HelpType) {
    // Verificar que no se haya usado antes en esta actividad
    const existingUsage = await this.helpUsageRepository.findOne({
      where: { userId, activityId, helpType }
    })

    if (existingUsage) {
      throw new BadRequestException('Help already used in this activity')
    }

    const cost = HELP_COSTS[helpType]

    // Gastar monedas
    await this.coinsService.spendCoins(userId, cost, 'help_used')

    // Registrar uso
    await this.helpUsageRepository.save({
      userId, activityId, helpType, coinsCost: cost
    })

    // Retornar resultado según tipo
    switch (helpType) {
      case HelpType.HINT:
        return await this.getHint(activityId)

      case HelpType.REMOVE_OPTION:
        return await this.getOptionsToRemove(activityId)

      case HelpType.EXTRA_TIME:
        return { extraSeconds: 30 }
    }
  }

  private async getHint(activityId: string) {
    const activity = await this.activitiesRepository.findOne({ where: { id: activityId } })
    return { hint: activity.content.hint || 'No hay pista disponible' }
  }

  private async getOptionsToRemove(activityId: string) {
    const activity = await this.activitiesRepository.findOne({ where: { id: activityId } })

    if (activity.type !== ActivityType.MULTIPLE_CHOICE) {
      throw new BadRequestException('Help not available for this activity type')
    }

    const incorrectOptions = activity.content.options
      .filter(opt => !opt.isCorrect)
      .slice(0, 2) // Eliminar hasta 2 opciones incorrectas

    return { optionsToRemove: incorrectOptions.map(o => o.id) }
  }
}

Endpoints:

POST /api/activities/:activityId/help/use
- Body: { helpType: 'hint' | 'remove_option' | 'extra_time' }
- Response: { hint?, optionsToRemove?, extraSeconds? }

GET /api/activities/:activityId/help/available
- Response: {
    helps: [
      { type: 'hint', cost: 5, used: false, available: true },
      { type: 'remove_option', cost: 10, used: false, available: true },
      { type: 'extra_time', cost: 15, used: true, available: false }
    ],
    userCoins: 45
  }

Frontend

// components/activities/HelpButtons.tsx
export function HelpButtons({ activityId, userCoins, onHelpUsed }) {
  const [helps, setHelps] = useState([])
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    loadAvailableHelps()
  }, [activityId])

  const handleUseHelp = async (helpType: HelpType) => {
    const help = helps.find(h => h.type === helpType)

    if (userCoins < help.cost) {
      toast.error('No tienes suficientes monedas')
      return
    }

    if (!window.confirm(`¿Usar ${help.type} por ${help.cost} monedas?`)) {
      return
    }

    setLoading(true)
    try {
      const result = await activitiesService.useHelp(activityId, helpType)
      onHelpUsed(helpType, result)
      await loadAvailableHelps() // Recargar disponibilidad
      toast.success('Ayuda aplicada')
    } catch (error) {
      toast.error(error.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="flex gap-2 mb-4">
      {helps.map(help => (
        <button
          key={help.type}
          onClick={() => handleUseHelp(help.type)}
          disabled={help.used || userCoins < help.cost || loading}
          className={`px-4 py-2 rounded-lg border-2 transition-all ${
            help.used
              ? 'bg-gray-100 border-gray-300 text-gray-400 cursor-not-allowed'
              : userCoins < help.cost
              ? 'bg-red-50 border-red-300 text-red-400 cursor-not-allowed'
              : 'bg-maya-gold-50 border-maya-gold-300 text-maya-gold-700 hover:bg-maya-gold-100'
          }`}
          title={help.used ? 'Ya usaste esta ayuda' : `Cuesta ${help.cost} monedas`}
        >
          {getHelpIcon(help.type)} {help.cost} 💰
        </button>
      ))}
    </div>
  )
}

function getHelpIcon(type: HelpType) {
  const icons = {
    hint: '💡',
    remove_option: '❌',
    extra_time: '⏰'
  }
  return icons[type]
}

// Uso en MultipleChoiceActivity
export function MultipleChoiceActivity({ activity }) {
  const [removedOptions, setRemovedOptions] = useState<string[]>([])
  const [hint, setHint] = useState<string | null>(null)

  const handleHelpUsed = (helpType: HelpType, result: any) => {
    if (helpType === 'hint') {
      setHint(result.hint)
    } else if (helpType === 'remove_option') {
      setRemovedOptions(result.optionsToRemove)
    }
  }

  return (
    <Card>
      <HelpButtons
        activityId={activity.id}
        userCoins={userCoins}
        onHelpUsed={handleHelpUsed}
      />

      {hint && (
        <div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-4">
          <p className="flex items-center gap-2">
            <span className="text-2xl">💡</span>
            <span className="text-blue-800">{hint}</span>
          </p>
        </div>
      )}

      <div className="space-y-3">
        {activity.content.options.map(option => (
          <label
            key={option.id}
            className={`
              ${removedOptions.includes(option.id) ? 'opacity-30 line-through cursor-not-allowed' : ''}
            `}
          >
            <input
              type="radio"
              disabled={removedOptions.includes(option.id)}
              // ...
            />
            {option.text}
          </label>
        ))}
      </div>
    </Card>
  )
}

Actualización de Content

interface MultipleChoiceContent {
  question: string
  options: Array<{ id, text, isCorrect }>
  explanation: string
  hint?: string // NUEVO: pista educativa
  imageUrl?: string
}

Dependencias

Antes:

  • US-GAM-003 (Monedas)
  • US-ACT-001, US-ACT-002 (Actividades)

Definición de Hecho (DoD)

  • 3 tipos de ayudas implementadas
  • Sistema de costos en monedas
  • Límite de 1 uso por tipo
  • Hints agregados a actividades
  • UI de botones de ayuda
  • Tests

Notas

  • 3 ayudas básicas
  • Costos fijos
  • Solo en actividades compatibles
  • ⚠️ Extensión futura: EXT-025-AdvancedHelps (más ayudas, costos dinámicos)

Estimación

Desglose (7 SP = ~2.5 días):

  • Backend: 1 día
  • Frontend: 1 día
  • Hints en seed data: 0.25 días
  • Testing: 0.25 días

Creado: 2025-11-02 Responsable: Equipo Fullstack