feat(economy): implement shop command with interactive panel for browsing offers
This commit is contained in:
617
SUGERENCIAS_Y_MEJORAS.md
Normal file
617
SUGERENCIAS_Y_MEJORAS.md
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
# 🎯 Análisis Completo y Sugerencias de Mejora
|
||||||
|
|
||||||
|
## 📊 Análisis del Proyecto
|
||||||
|
|
||||||
|
Después de analizar todo tu código, he identificado que tienes un **excelente sistema base** con:
|
||||||
|
|
||||||
|
✅ Sistema de economía robusto (items, wallet, inventario)
|
||||||
|
✅ Sistema de minijuegos completo (mina, pesca, pelea, granja)
|
||||||
|
✅ Sistema de combate con equipamiento y stats
|
||||||
|
✅ Sistema de crafteo y fundición
|
||||||
|
✅ Sistema de mutaciones para items
|
||||||
|
✅ DisplayComponents avanzado con editor visual
|
||||||
|
✅ Sistema de cooldowns y progresión
|
||||||
|
✅ Sistema de alianzas con leaderboards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Sugerencias de Nuevas Funcionalidades
|
||||||
|
|
||||||
|
### 1. 🏆 **Sistema de Logros/Achievements** ⭐⭐⭐⭐⭐
|
||||||
|
|
||||||
|
**Por qué es importante:** Los logros mantienen a los jugadores enganchados con objetivos claros.
|
||||||
|
|
||||||
|
**Implementación sugerida:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// prisma/schema.prisma
|
||||||
|
model Achievement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String // "first_mine", "craft_10_items", "defeat_100_mobs"
|
||||||
|
name String
|
||||||
|
description String
|
||||||
|
icon String?
|
||||||
|
category String // "mining", "crafting", "combat", "economy"
|
||||||
|
|
||||||
|
// Requisitos para desbloquear (JSON flexible)
|
||||||
|
requirements Json // { type: "mine_count", value: 100 }
|
||||||
|
|
||||||
|
// Recompensas al desbloquear
|
||||||
|
rewards Json? // { coins: 500, items: [...], title: "..." }
|
||||||
|
|
||||||
|
guildId String?
|
||||||
|
guild Guild? @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
// Logros desbloqueados por usuarios
|
||||||
|
unlocked PlayerAchievement[]
|
||||||
|
|
||||||
|
hidden Boolean @default(false) // logros secretos
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([guildId, key])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PlayerAchievement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
achievementId String
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
achievement Achievement @relation(fields: [achievementId], references: [id])
|
||||||
|
|
||||||
|
unlockedAt DateTime @default(now())
|
||||||
|
metadata Json? // datos extra como progreso
|
||||||
|
|
||||||
|
@@unique([userId, guildId, achievementId])
|
||||||
|
@@index([userId, guildId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comandos sugeridos:**
|
||||||
|
- `!logros` - Ver tus logros desbloqueados y progreso
|
||||||
|
- `!logros @usuario` - Ver logros de otro usuario
|
||||||
|
- `!logro-crear <key>` - Crear nuevo logro (admin)
|
||||||
|
- `!ranking-logros` - Ver quién tiene más logros
|
||||||
|
|
||||||
|
**Ejemplos de logros:**
|
||||||
|
- 🎖️ **Primera Mina**: Mina por primera vez
|
||||||
|
- ⛏️ **Minero Novato**: Mina 50 veces
|
||||||
|
- ⛏️ **Minero Veterano**: Mina 500 veces
|
||||||
|
- 🎣 **Pescador Experto**: Pesca 100 veces
|
||||||
|
- ⚔️ **Cazador de Monstruos**: Derrota 100 mobs
|
||||||
|
- 💰 **Millonario**: Acumula 1,000,000 monedas
|
||||||
|
- 🛠️ **Maestro Craftero**: Craftea 100 items
|
||||||
|
- 📦 **Coleccionista**: Ten 50 items únicos
|
||||||
|
- 🗡️ **Arsenal Completo**: Equipa arma, armadura y capa legendarias
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 📊 **Sistema de Estadísticas Detalladas** ⭐⭐⭐⭐
|
||||||
|
|
||||||
|
**Por qué es importante:** A los jugadores les gusta ver su progreso en números.
|
||||||
|
|
||||||
|
**Implementación sugerida:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// prisma/schema.prisma
|
||||||
|
model PlayerStats {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
|
||||||
|
// Stats de minijuegos
|
||||||
|
minesCompleted Int @default(0)
|
||||||
|
fishingCompleted Int @default(0)
|
||||||
|
fightsCompleted Int @default(0)
|
||||||
|
farmsCompleted Int @default(0)
|
||||||
|
|
||||||
|
// Stats de combate
|
||||||
|
mobsDefeated Int @default(0)
|
||||||
|
damageDealt Int @default(0)
|
||||||
|
damageTaken Int @default(0)
|
||||||
|
|
||||||
|
// Stats de economía
|
||||||
|
totalCoinsEarned Int @default(0)
|
||||||
|
totalCoinsSpent Int @default(0)
|
||||||
|
itemsCrafted Int @default(0)
|
||||||
|
itemsSmelted Int @default(0)
|
||||||
|
|
||||||
|
// Stats de items
|
||||||
|
chestsOpened Int @default(0)
|
||||||
|
itemsConsumed Int @default(0)
|
||||||
|
|
||||||
|
// Récords personales
|
||||||
|
highestDamageDealt Int @default(0)
|
||||||
|
longestWinStreak Int @default(0)
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, guildId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comando sugerido:**
|
||||||
|
```
|
||||||
|
!stats [@usuario]
|
||||||
|
```
|
||||||
|
|
||||||
|
Muestra:
|
||||||
|
- Total de actividades realizadas
|
||||||
|
- Mobs derrotados
|
||||||
|
- Monedas ganadas/gastadas
|
||||||
|
- Items crafteados
|
||||||
|
- Récords personales
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 🎁 **Sistema de Misiones Diarias/Semanales** ⭐⭐⭐⭐⭐
|
||||||
|
|
||||||
|
**Por qué es importante:** Mantiene a los jugadores volviendo cada día.
|
||||||
|
|
||||||
|
**Implementación sugerida:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// prisma/schema.prisma
|
||||||
|
model Quest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String
|
||||||
|
name String
|
||||||
|
description String
|
||||||
|
icon String?
|
||||||
|
|
||||||
|
// Tipo de misión
|
||||||
|
type String // "daily", "weekly", "event"
|
||||||
|
category String // "mining", "combat", "economy"
|
||||||
|
|
||||||
|
// Requisitos
|
||||||
|
requirements Json // { type: "mine", count: 10 }
|
||||||
|
|
||||||
|
// Recompensas
|
||||||
|
rewards Json // { coins: 500, items: [...], xp: 100 }
|
||||||
|
|
||||||
|
// Disponibilidad
|
||||||
|
startAt DateTime?
|
||||||
|
endAt DateTime?
|
||||||
|
|
||||||
|
guildId String?
|
||||||
|
guild Guild? @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
progress QuestProgress[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([guildId, key])
|
||||||
|
}
|
||||||
|
|
||||||
|
model QuestProgress {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
questId String
|
||||||
|
|
||||||
|
progress Int @default(0) // progreso actual
|
||||||
|
completed Boolean @default(false)
|
||||||
|
claimed Boolean @default(false) // si ya reclamó recompensa
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
quest Quest @relation(fields: [questId], references: [id])
|
||||||
|
|
||||||
|
completedAt DateTime?
|
||||||
|
claimedAt DateTime?
|
||||||
|
expiresAt DateTime? // para misiones diarias/semanales
|
||||||
|
|
||||||
|
@@unique([userId, guildId, questId])
|
||||||
|
@@index([userId, guildId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comandos sugeridos:**
|
||||||
|
- `!misiones` - Ver misiones disponibles y progreso
|
||||||
|
- `!mision-reclamar <id>` - Reclamar recompensa de misión completada
|
||||||
|
- `!mision-crear <key>` - Crear nueva misión (admin)
|
||||||
|
|
||||||
|
**Ejemplos de misiones diarias:**
|
||||||
|
- ⛏️ Mina 10 veces (Recompensa: 500 monedas)
|
||||||
|
- 🎣 Pesca 5 veces (Recompensa: 3 cañas)
|
||||||
|
- ⚔️ Derrota 15 mobs (Recompensa: poción de vida)
|
||||||
|
- 🛒 Compra 3 items de la tienda (Recompensa: 10% descuento)
|
||||||
|
- 🛠️ Craftea 5 items (Recompensa: materiales extra)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 🏪 **Comando de Tienda Mejorado** ⭐⭐⭐⭐⭐
|
||||||
|
|
||||||
|
**Lo que falta actualmente:**
|
||||||
|
- Solo tienes `!comprar <offerId>` que requiere saber el ID
|
||||||
|
- No hay forma visual de ver la tienda
|
||||||
|
- No hay categorías ni filtros
|
||||||
|
- No hay vista previa de items
|
||||||
|
|
||||||
|
**Voy a crear un comando completo con DisplayComponents** ⬇️
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 🎲 **Sistema de Eventos Temporales** ⭐⭐⭐⭐
|
||||||
|
|
||||||
|
**Por qué es importante:** Crea urgencia y mantiene el contenido fresco.
|
||||||
|
|
||||||
|
**Implementación sugerida:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// prisma/schema.prisma
|
||||||
|
model Event {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String
|
||||||
|
name String
|
||||||
|
description String
|
||||||
|
icon String?
|
||||||
|
|
||||||
|
// Configuración del evento
|
||||||
|
config Json? // multiplicadores, drops especiales, etc.
|
||||||
|
|
||||||
|
// Items/áreas/mobs específicos del evento
|
||||||
|
specialItems String[] // keys de items que solo aparecen en evento
|
||||||
|
|
||||||
|
// Fechas
|
||||||
|
startAt DateTime
|
||||||
|
endAt DateTime
|
||||||
|
|
||||||
|
guildId String?
|
||||||
|
guild Guild? @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([guildId, key])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplos de eventos:**
|
||||||
|
- 🎃 **Halloween:** Mobs especiales, items de Halloween, drops x2
|
||||||
|
- 🎄 **Navidad:** Cofres navideños, items temáticos, monedas x1.5
|
||||||
|
- 🐰 **Pascua:** Huevos de Pascua escondidos, mobs conejos
|
||||||
|
- 💘 **San Valentín:** Items de amor, crafteos especiales
|
||||||
|
|
||||||
|
**Comando sugerido:**
|
||||||
|
```
|
||||||
|
!eventos
|
||||||
|
```
|
||||||
|
Muestra eventos activos con tiempo restante y recompensas especiales.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 🔄 **Sistema de Intercambio entre Jugadores** ⭐⭐⭐⭐
|
||||||
|
|
||||||
|
**Por qué es importante:** Fomenta la economía entre jugadores.
|
||||||
|
|
||||||
|
**Implementación sugerida:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// prisma/schema.prisma
|
||||||
|
model Trade {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// Jugador que inicia el trade
|
||||||
|
initiatorId String
|
||||||
|
initiator User @relation("TradeInitiator", fields: [initiatorId], references: [id])
|
||||||
|
|
||||||
|
// Jugador que recibe la oferta
|
||||||
|
targetId String
|
||||||
|
target User @relation("TradeTarget", fields: [targetId], references: [id])
|
||||||
|
|
||||||
|
guildId String
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
// Ofertas de ambos jugadores (JSON)
|
||||||
|
initiatorOffer Json // { coins: 100, items: [{ key, qty }] }
|
||||||
|
targetOffer Json // { coins: 50, items: [{ key, qty }] }
|
||||||
|
|
||||||
|
status String // "pending", "accepted", "rejected", "expired", "completed"
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiresAt DateTime
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
@@index([initiatorId, targetId, guildId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comandos sugeridos:**
|
||||||
|
- `!tradear @usuario` - Iniciar intercambio
|
||||||
|
- `!trade-ofrecer <coins|item:key:qty>` - Añadir a tu oferta
|
||||||
|
- `!trade-aceptar` - Aceptar intercambio
|
||||||
|
- `!trade-cancelar` - Cancelar intercambio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 🏘️ **Sistema de Guilds/Clanes** ⭐⭐⭐⭐⭐
|
||||||
|
|
||||||
|
**Por qué es importante:** Fomenta el trabajo en equipo y competencia.
|
||||||
|
|
||||||
|
**Implementación sugerida:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// prisma/schema.prisma
|
||||||
|
model PlayerGuild {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
tag String // [TAG]
|
||||||
|
description String?
|
||||||
|
icon String?
|
||||||
|
|
||||||
|
leaderId String
|
||||||
|
leader User @relation(fields: [leaderId], references: [id])
|
||||||
|
|
||||||
|
guildId String // Discord guild
|
||||||
|
discordGuild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
// Stats del clan
|
||||||
|
level Int @default(1)
|
||||||
|
xp Int @default(0)
|
||||||
|
|
||||||
|
// Recursos del clan
|
||||||
|
treasury Int @default(0) // monedas compartidas
|
||||||
|
|
||||||
|
members PlayerGuildMember[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([guildId, tag])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PlayerGuildMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
playerGuildId String
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
playerGuild PlayerGuild @relation(fields: [playerGuildId], references: [id])
|
||||||
|
|
||||||
|
rank String @default("member") // "leader", "officer", "member"
|
||||||
|
contribution Int @default(0) // contribución total
|
||||||
|
|
||||||
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([userId, playerGuildId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comandos sugeridos:**
|
||||||
|
- `!clan-crear <nombre> <tag>` - Crear clan
|
||||||
|
- `!clan-info [@clan]` - Ver info del clan
|
||||||
|
- `!clan-invitar @usuario` - Invitar a clan
|
||||||
|
- `!clan-donar <cantidad>` - Donar al tesoro
|
||||||
|
- `!clan-ranking` - Ver ranking de clanes
|
||||||
|
- `!clan-buffs` - Buffs activos del clan
|
||||||
|
|
||||||
|
**Beneficios de clanes:**
|
||||||
|
- Buffs compartidos (XP+10%, drops+5%, etc.)
|
||||||
|
- Almacén compartido
|
||||||
|
- Misiones de clan con recompensas grupales
|
||||||
|
- Guerras entre clanes
|
||||||
|
- Ranking de clanes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 🎰 **Sistema de Ruleta/Lotería** ⭐⭐⭐
|
||||||
|
|
||||||
|
**Por qué es importante:** Da emoción y oportunidades de ganar grande.
|
||||||
|
|
||||||
|
**Comando sugerido:**
|
||||||
|
```
|
||||||
|
!ruleta <apuesta>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Cuesta monedas
|
||||||
|
- Puede dar: monedas x2, x5, x10, items raros, o perder todo
|
||||||
|
- Cooldown de 1 hora
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 🏠 **Sistema de Viviendas/Bases** ⭐⭐⭐⭐
|
||||||
|
|
||||||
|
**Por qué es importante:** Personalización y progresión a largo plazo.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- Cada jugador puede tener una casa
|
||||||
|
- Mejoras de casa (nivel 1-10)
|
||||||
|
- Almacén extra por nivel
|
||||||
|
- Decoraciones
|
||||||
|
- Buffs pasivos (+5% XP, -10% cooldowns, etc.)
|
||||||
|
- Costo de mantenimiento semanal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. 🔥 **Sistema de Racha (Streaks)** ⭐⭐⭐⭐
|
||||||
|
|
||||||
|
**Por qué es importante:** Incentiva jugar todos los días.
|
||||||
|
|
||||||
|
**Implementación:**
|
||||||
|
```typescript
|
||||||
|
model PlayerStreak {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
|
||||||
|
currentStreak Int @default(0)
|
||||||
|
longestStreak Int @default(0)
|
||||||
|
lastActiveDate DateTime
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
@@unique([userId, guildId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recompensas por racha:**
|
||||||
|
- Día 1: 100 monedas
|
||||||
|
- Día 3: 300 monedas + cofre básico
|
||||||
|
- Día 7: 1000 monedas + cofre raro
|
||||||
|
- Día 14: 3000 monedas + cofre épico
|
||||||
|
- Día 30: 10000 monedas + item legendario
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Bugs y Mejoras Técnicas Detectadas
|
||||||
|
|
||||||
|
### 1. **Sistema de Cooldowns**
|
||||||
|
**Problema:** Los cooldowns están dispersos por actividad.
|
||||||
|
**Mejora:** Centralizar en una función helper que muestre todos los cooldowns activos de un usuario.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/game/cooldowns/service.ts
|
||||||
|
export async function getUserActiveCooldowns(userId: string, guildId: string) {
|
||||||
|
const cds = await prisma.actionCooldown.findMany({
|
||||||
|
where: { userId, guildId, until: { gt: new Date() } },
|
||||||
|
orderBy: { until: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return cds.map(cd => ({
|
||||||
|
key: cd.key,
|
||||||
|
remaining: Math.ceil((cd.until.getTime() - Date.now()) / 1000),
|
||||||
|
expiresAt: cd.until
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Validación de Props en Items**
|
||||||
|
**Mejora:** Añadir validación TypeScript más estricta para props de items.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/game/economy/validators.ts
|
||||||
|
export function validateItemProps(props: any): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (props.tool) {
|
||||||
|
if (!['pickaxe', 'rod', 'sword', 'bow', 'halberd', 'net'].includes(props.tool.type)) {
|
||||||
|
errors.push(`Invalid tool type: ${props.tool.type}`);
|
||||||
|
}
|
||||||
|
if (props.tool.tier && (props.tool.tier < 1 || props.tool.tier > 10)) {
|
||||||
|
errors.push(`Tool tier must be between 1-10`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.damage && props.damage < 0) {
|
||||||
|
errors.push(`Damage cannot be negative`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... más validaciones
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Sistema de Paginación Mejorado**
|
||||||
|
**Mejora:** Crear componente reutilizable para paginación en todos los comandos.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/core/lib/pagination.ts
|
||||||
|
export function paginate<T>(items: T[], page: number, perPage: number) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(items.length / perPage));
|
||||||
|
const safePage = Math.min(Math.max(1, page), totalPages);
|
||||||
|
const start = (safePage - 1) * perPage;
|
||||||
|
const end = start + perPage;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.slice(start, end),
|
||||||
|
page: safePage,
|
||||||
|
totalPages,
|
||||||
|
hasNext: safePage < totalPages,
|
||||||
|
hasPrev: safePage > 1,
|
||||||
|
total: items.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **Logs y Auditoría**
|
||||||
|
**Mejora:** Añadir sistema de logs para operaciones importantes.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// prisma/schema.prisma
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
action String // "buy", "craft", "trade", "equip", etc.
|
||||||
|
target String? // ID del item/mob/área afectado
|
||||||
|
details Json? // detalles adicionales
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId, guildId])
|
||||||
|
@@index([action])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Prioridades Recomendadas
|
||||||
|
|
||||||
|
### Alta Prioridad (Implementar Ya)
|
||||||
|
1. ✅ **Comando de Tienda con DisplayComponents** (lo voy a crear)
|
||||||
|
2. 🏆 **Sistema de Logros**
|
||||||
|
3. 🎁 **Misiones Diarias**
|
||||||
|
4. 🔥 **Sistema de Rachas**
|
||||||
|
|
||||||
|
### Media Prioridad (Próximos Sprints)
|
||||||
|
5. 📊 **Estadísticas Detalladas**
|
||||||
|
6. 🏘️ **Sistema de Clanes**
|
||||||
|
7. 🎲 **Eventos Temporales**
|
||||||
|
|
||||||
|
### Baja Prioridad (Largo Plazo)
|
||||||
|
8. 🔄 **Intercambio entre Jugadores**
|
||||||
|
9. 🏠 **Sistema de Viviendas**
|
||||||
|
10. 🎰 **Ruleta/Lotería**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Mejoras de UX/UI
|
||||||
|
|
||||||
|
### 1. Mensajes más Visuales
|
||||||
|
Usar más DisplayComponents en lugar de texto plano:
|
||||||
|
- `!inventario` → DisplayComponents con imágenes
|
||||||
|
- `!player` → DisplayComponents con stats visuales
|
||||||
|
- `!tienda` → DisplayComponents con preview de items
|
||||||
|
|
||||||
|
### 2. Botones Interactivos
|
||||||
|
Añadir botones en lugar de comandos:
|
||||||
|
- `!mina` → Añadir botones "Minar Otra Vez", "Ver Inventario"
|
||||||
|
- `!pelear` → Añadir botones "Pelear de Nuevo", "Equipar Mejor Arma"
|
||||||
|
- `!tienda` → Añadir botones de compra directa
|
||||||
|
|
||||||
|
### 3. Confirmaciones
|
||||||
|
Añadir confirmaciones para acciones importantes:
|
||||||
|
- Comprar items caros
|
||||||
|
- Fundir items
|
||||||
|
- Eliminar items
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Conclusión
|
||||||
|
|
||||||
|
Tu bot tiene una **base excelente y muy completa**. Las sugerencias principales son:
|
||||||
|
|
||||||
|
1. **Más engagement:** Logros, misiones, rachas
|
||||||
|
2. **Más social:** Clanes, trades, rankings
|
||||||
|
3. **Más visual:** DisplayComponents en todos lados
|
||||||
|
4. **Más feedback:** Stats, logs, notificaciones
|
||||||
|
|
||||||
|
Ahora voy a crear el **comando de tienda completo con DisplayComponents** 🚀
|
||||||
@@ -52,6 +52,15 @@ model Guild {
|
|||||||
ActionCooldown ActionCooldown[]
|
ActionCooldown ActionCooldown[]
|
||||||
SmeltJob SmeltJob[]
|
SmeltJob SmeltJob[]
|
||||||
ScheduledMobAttack ScheduledMobAttack[]
|
ScheduledMobAttack ScheduledMobAttack[]
|
||||||
|
|
||||||
|
// Nuevas relaciones para sistemas de engagement
|
||||||
|
Achievement Achievement[]
|
||||||
|
PlayerAchievement PlayerAchievement[]
|
||||||
|
Quest Quest[]
|
||||||
|
QuestProgress QuestProgress[]
|
||||||
|
PlayerStats PlayerStats[]
|
||||||
|
PlayerStreak PlayerStreak[]
|
||||||
|
AuditLog AuditLog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +89,13 @@ model User {
|
|||||||
ActionCooldown ActionCooldown[]
|
ActionCooldown ActionCooldown[]
|
||||||
SmeltJob SmeltJob[]
|
SmeltJob SmeltJob[]
|
||||||
ScheduledMobAttack ScheduledMobAttack[]
|
ScheduledMobAttack ScheduledMobAttack[]
|
||||||
|
|
||||||
|
// Nuevas relaciones para sistemas de engagement
|
||||||
|
PlayerAchievement PlayerAchievement[]
|
||||||
|
QuestProgress QuestProgress[]
|
||||||
|
PlayerStats PlayerStats[]
|
||||||
|
PlayerStreak PlayerStreak[]
|
||||||
|
AuditLog AuditLog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -743,3 +759,230 @@ model ScheduledMobAttack {
|
|||||||
@@index([scheduleAt])
|
@@index([scheduleAt])
|
||||||
@@index([userId, guildId])
|
@@index([userId, guildId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
* Sistema de Logros (Achievements)
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
model Achievement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String
|
||||||
|
name String
|
||||||
|
description String
|
||||||
|
icon String?
|
||||||
|
category String // "mining", "crafting", "combat", "economy", "exploration"
|
||||||
|
|
||||||
|
// Requisitos para desbloquear (JSON flexible)
|
||||||
|
requirements Json // { type: "mine_count", value: 100 }
|
||||||
|
|
||||||
|
// Recompensas al desbloquear
|
||||||
|
rewards Json? // { coins: 500, items: [...], title: "..." }
|
||||||
|
|
||||||
|
guildId String?
|
||||||
|
guild Guild? @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
// Logros desbloqueados por usuarios
|
||||||
|
unlocked PlayerAchievement[]
|
||||||
|
|
||||||
|
hidden Boolean @default(false) // logros secretos
|
||||||
|
points Int @default(10) // puntos que otorga el logro
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([guildId, key])
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PlayerAchievement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
achievementId String
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
achievement Achievement @relation(fields: [achievementId], references: [id])
|
||||||
|
|
||||||
|
progress Int @default(0) // progreso actual hacia el logro
|
||||||
|
unlockedAt DateTime? // null si aún no está desbloqueado
|
||||||
|
notified Boolean @default(false) // si ya se notificó al usuario
|
||||||
|
metadata Json?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, guildId, achievementId])
|
||||||
|
@@index([userId, guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
* Sistema de Misiones (Quests)
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
model Quest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String
|
||||||
|
name String
|
||||||
|
description String
|
||||||
|
icon String?
|
||||||
|
|
||||||
|
// Tipo de misión
|
||||||
|
type String // "daily", "weekly", "event", "permanent"
|
||||||
|
category String // "mining", "combat", "economy", "exploration"
|
||||||
|
|
||||||
|
// Requisitos
|
||||||
|
requirements Json // { type: "mine", count: 10 }
|
||||||
|
|
||||||
|
// Recompensas
|
||||||
|
rewards Json // { coins: 500, items: [...], xp: 100 }
|
||||||
|
|
||||||
|
// Disponibilidad
|
||||||
|
startAt DateTime?
|
||||||
|
endAt DateTime?
|
||||||
|
|
||||||
|
guildId String?
|
||||||
|
guild Guild? @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
progress QuestProgress[]
|
||||||
|
|
||||||
|
active Boolean @default(true)
|
||||||
|
repeatable Boolean @default(false) // si se puede repetir después de completar
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([guildId, key])
|
||||||
|
@@index([guildId])
|
||||||
|
@@index([type])
|
||||||
|
}
|
||||||
|
|
||||||
|
model QuestProgress {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
questId String
|
||||||
|
|
||||||
|
progress Int @default(0) // progreso actual
|
||||||
|
completed Boolean @default(false)
|
||||||
|
claimed Boolean @default(false) // si ya reclamó recompensa
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
quest Quest @relation(fields: [questId], references: [id])
|
||||||
|
|
||||||
|
completedAt DateTime?
|
||||||
|
claimedAt DateTime?
|
||||||
|
expiresAt DateTime? // para misiones diarias/semanales
|
||||||
|
metadata Json?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, guildId, questId, expiresAt])
|
||||||
|
@@index([userId, guildId])
|
||||||
|
@@index([questId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
* Sistema de Estadísticas del Jugador
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
model PlayerStats {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
|
||||||
|
// Stats de minijuegos
|
||||||
|
minesCompleted Int @default(0)
|
||||||
|
fishingCompleted Int @default(0)
|
||||||
|
fightsCompleted Int @default(0)
|
||||||
|
farmsCompleted Int @default(0)
|
||||||
|
|
||||||
|
// Stats de combate
|
||||||
|
mobsDefeated Int @default(0)
|
||||||
|
damageDealt Int @default(0)
|
||||||
|
damageTaken Int @default(0)
|
||||||
|
timesDefeated Int @default(0)
|
||||||
|
|
||||||
|
// Stats de economía
|
||||||
|
totalCoinsEarned Int @default(0)
|
||||||
|
totalCoinsSpent Int @default(0)
|
||||||
|
itemsCrafted Int @default(0)
|
||||||
|
itemsSmelted Int @default(0)
|
||||||
|
itemsPurchased Int @default(0)
|
||||||
|
|
||||||
|
// Stats de items
|
||||||
|
chestsOpened Int @default(0)
|
||||||
|
itemsConsumed Int @default(0)
|
||||||
|
itemsEquipped Int @default(0)
|
||||||
|
|
||||||
|
// Récords personales
|
||||||
|
highestDamageDealt Int @default(0)
|
||||||
|
longestWinStreak Int @default(0)
|
||||||
|
currentWinStreak Int @default(0)
|
||||||
|
mostCoinsAtOnce Int @default(0)
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, guildId])
|
||||||
|
@@index([userId, guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
* Sistema de Rachas (Streaks)
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
model PlayerStreak {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
|
||||||
|
currentStreak Int @default(0)
|
||||||
|
longestStreak Int @default(0)
|
||||||
|
lastActiveDate DateTime @default(now())
|
||||||
|
totalDaysActive Int @default(0)
|
||||||
|
|
||||||
|
// Recompensas reclamadas por día
|
||||||
|
rewardsClaimed Json? // { day3: true, day7: true, etc }
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, guildId])
|
||||||
|
@@index([userId, guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
* Log de Auditoría
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String
|
||||||
|
action String // "buy", "craft", "trade", "equip", "mine", "fight", etc.
|
||||||
|
target String? // ID del item/mob/área afectado
|
||||||
|
details Json? // detalles adicionales
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId, guildId])
|
||||||
|
@@index([action])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|||||||
497
src/commands/messages/game/tienda.ts
Normal file
497
src/commands/messages/game/tienda.ts
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import type { CommandMessage } from '../../../core/types/commands';
|
||||||
|
import type Amayo from '../../../core/client';
|
||||||
|
import {
|
||||||
|
Message,
|
||||||
|
ButtonInteraction,
|
||||||
|
MessageComponentInteraction,
|
||||||
|
ComponentType,
|
||||||
|
ButtonStyle,
|
||||||
|
MessageFlags,
|
||||||
|
StringSelectMenuInteraction
|
||||||
|
} from 'discord.js';
|
||||||
|
import { prisma } from '../../../core/database/prisma';
|
||||||
|
import { getOrCreateWallet, buyFromOffer } from '../../../game/economy/service';
|
||||||
|
import type { DisplayComponentContainer } from '../../../core/types/displayComponents';
|
||||||
|
import type { ItemProps } from '../../../game/economy/types';
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 5;
|
||||||
|
|
||||||
|
function parseItemProps(json: unknown): ItemProps {
|
||||||
|
if (!json || typeof json !== 'object') return {};
|
||||||
|
return json as ItemProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: any): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (price.coins) parts.push(`💰 ${price.coins}`);
|
||||||
|
if (price.items && price.items.length > 0) {
|
||||||
|
for (const item of price.items) {
|
||||||
|
parts.push(`📦 ${item.itemKey} x${item.qty}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(' + ') || '¿Gratis?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemIcon(props: ItemProps, category?: string): string {
|
||||||
|
if (props.tool) {
|
||||||
|
const t = props.tool.type;
|
||||||
|
if (t === 'pickaxe') return '⛏️';
|
||||||
|
if (t === 'rod') return '🎣';
|
||||||
|
if (t === 'sword') return '🗡️';
|
||||||
|
if (t === 'bow') return '🏹';
|
||||||
|
if (t === 'halberd') return '⚔️';
|
||||||
|
if (t === 'net') return '🕸️';
|
||||||
|
return '🔧';
|
||||||
|
}
|
||||||
|
if (props.damage && props.damage > 0) return '⚔️';
|
||||||
|
if (props.defense && props.defense > 0) return '🛡️';
|
||||||
|
if (props.food) return '🍖';
|
||||||
|
if (props.chest) return '📦';
|
||||||
|
if (category === 'consumables') return '🧪';
|
||||||
|
if (category === 'materials') return '🔨';
|
||||||
|
return '📦';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const command: CommandMessage = {
|
||||||
|
name: 'tienda',
|
||||||
|
type: 'message',
|
||||||
|
aliases: ['shop', 'store'],
|
||||||
|
cooldown: 5,
|
||||||
|
description: 'Abre la tienda y navega por las ofertas disponibles con un panel interactivo.',
|
||||||
|
usage: 'tienda [categoria]',
|
||||||
|
run: async (message, args, _client: Amayo) => {
|
||||||
|
const userId = message.author.id;
|
||||||
|
const guildId = message.guild!.id;
|
||||||
|
|
||||||
|
// Obtener wallet del usuario
|
||||||
|
const wallet = await getOrCreateWallet(userId, guildId);
|
||||||
|
|
||||||
|
// Obtener todas las ofertas activas
|
||||||
|
const now = new Date();
|
||||||
|
const offers = await prisma.shopOffer.findMany({
|
||||||
|
where: {
|
||||||
|
guildId,
|
||||||
|
enabled: true,
|
||||||
|
OR: [
|
||||||
|
{ startAt: null, endAt: null },
|
||||||
|
{ startAt: { lte: now }, endAt: { gte: now } },
|
||||||
|
{ startAt: { lte: now }, endAt: null },
|
||||||
|
{ startAt: null, endAt: { gte: now } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: { item: true },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (offers.length === 0) {
|
||||||
|
await message.reply('🏪 La tienda está vacía. ¡Vuelve más tarde!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrar por categoría si se proporciona
|
||||||
|
const categoryFilter = args[0]?.trim().toLowerCase();
|
||||||
|
const filteredOffers = categoryFilter
|
||||||
|
? offers.filter(o => o.item.category?.toLowerCase().includes(categoryFilter))
|
||||||
|
: offers;
|
||||||
|
|
||||||
|
if (filteredOffers.length === 0) {
|
||||||
|
await message.reply(`🏪 No hay ofertas en la categoría "${categoryFilter}".`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado inicial
|
||||||
|
let currentPage = 1;
|
||||||
|
let selectedOfferId: string | null = null;
|
||||||
|
|
||||||
|
const shopMessage = await message.reply({
|
||||||
|
flags: MessageFlags.SuppressEmbeds | 32768,
|
||||||
|
components: await buildShopPanel(filteredOffers, currentPage, wallet.coins, selectedOfferId)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collector para interacciones
|
||||||
|
const collector = shopMessage.createMessageComponentCollector({
|
||||||
|
time: 300000, // 5 minutos
|
||||||
|
filter: (i: MessageComponentInteraction) => i.user.id === message.author.id
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (interaction: MessageComponentInteraction) => {
|
||||||
|
try {
|
||||||
|
if (interaction.isButton()) {
|
||||||
|
await handleButtonInteraction(
|
||||||
|
interaction as ButtonInteraction,
|
||||||
|
filteredOffers,
|
||||||
|
currentPage,
|
||||||
|
selectedOfferId,
|
||||||
|
userId,
|
||||||
|
guildId,
|
||||||
|
shopMessage,
|
||||||
|
collector
|
||||||
|
);
|
||||||
|
} else if (interaction.isStringSelectMenu()) {
|
||||||
|
await handleSelectInteraction(
|
||||||
|
interaction as StringSelectMenuInteraction,
|
||||||
|
filteredOffers,
|
||||||
|
currentPage,
|
||||||
|
userId,
|
||||||
|
guildId,
|
||||||
|
shopMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar página y selección basado en customId
|
||||||
|
if (interaction.customId === 'shop_prev_page') {
|
||||||
|
currentPage = Math.max(1, currentPage - 1);
|
||||||
|
} else if (interaction.customId === 'shop_next_page') {
|
||||||
|
const totalPages = Math.ceil(filteredOffers.length / ITEMS_PER_PAGE);
|
||||||
|
currentPage = Math.min(totalPages, currentPage + 1);
|
||||||
|
} else if (interaction.customId === 'shop_select_item') {
|
||||||
|
// El select menu ya maneja la selección
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error handling shop interaction:', error);
|
||||||
|
if (!interaction.replied && !interaction.deferred) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: `❌ Error: ${error?.message ?? error}`,
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', async (_, reason) => {
|
||||||
|
if (reason === 'time') {
|
||||||
|
try {
|
||||||
|
await shopMessage.edit({
|
||||||
|
components: await buildExpiredPanel()
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function buildShopPanel(
|
||||||
|
offers: any[],
|
||||||
|
page: number,
|
||||||
|
userCoins: number,
|
||||||
|
selectedOfferId: string | null
|
||||||
|
): Promise<any[]> {
|
||||||
|
const totalPages = Math.ceil(offers.length / ITEMS_PER_PAGE);
|
||||||
|
const safePage = Math.min(Math.max(1, page), totalPages);
|
||||||
|
const start = (safePage - 1) * ITEMS_PER_PAGE;
|
||||||
|
const pageOffers = offers.slice(start, start + ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
// Encontrar la oferta seleccionada
|
||||||
|
const selectedOffer = selectedOfferId
|
||||||
|
? offers.find(o => o.id === selectedOfferId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Container principal
|
||||||
|
const container: DisplayComponentContainer = {
|
||||||
|
type: 17,
|
||||||
|
accent_color: 0xffa500,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: `🏪 **TIENDA** | Página ${safePage}/${totalPages}\n💰 Tus Monedas: **${userCoins}**`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 14,
|
||||||
|
divider: true,
|
||||||
|
spacing: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Si hay una oferta seleccionada, mostrar detalles
|
||||||
|
if (selectedOffer) {
|
||||||
|
const item = selectedOffer.item;
|
||||||
|
const props = parseItemProps(item.props);
|
||||||
|
const icon = getItemIcon(props, item.category);
|
||||||
|
const price = formatPrice(selectedOffer.price);
|
||||||
|
|
||||||
|
// Stock info
|
||||||
|
let stockInfo = '';
|
||||||
|
if (selectedOffer.stock != null) {
|
||||||
|
stockInfo = `\n📊 Stock: ${selectedOffer.stock}`;
|
||||||
|
}
|
||||||
|
if (selectedOffer.perUserLimit != null) {
|
||||||
|
const purchased = await prisma.shopPurchase.aggregate({
|
||||||
|
where: { offerId: selectedOffer.id },
|
||||||
|
_sum: { qty: true }
|
||||||
|
});
|
||||||
|
const userPurchased = purchased._sum.qty ?? 0;
|
||||||
|
stockInfo += `\n👤 Límite por usuario: ${userPurchased}/${selectedOffer.perUserLimit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats del item
|
||||||
|
let statsInfo = '';
|
||||||
|
if (props.damage) statsInfo += `\n⚔️ Daño: +${props.damage}`;
|
||||||
|
if (props.defense) statsInfo += `\n🛡️ Defensa: +${props.defense}`;
|
||||||
|
if (props.maxHpBonus) statsInfo += `\n❤️ HP Bonus: +${props.maxHpBonus}`;
|
||||||
|
if (props.tool) statsInfo += `\n🔧 Herramienta: ${props.tool.type} T${props.tool.tier ?? 1}`;
|
||||||
|
if (props.food && props.food.healHp) statsInfo += `\n🍖 Cura: ${props.food.healHp} HP`;
|
||||||
|
|
||||||
|
container.components.push({
|
||||||
|
type: 10,
|
||||||
|
content: `${icon} **${item.name || item.key}**\n\n${item.description || 'Sin descripción'}${statsInfo}\n\n💰 Precio: ${price}${stockInfo}`
|
||||||
|
});
|
||||||
|
|
||||||
|
container.components.push({
|
||||||
|
type: 14,
|
||||||
|
divider: true,
|
||||||
|
spacing: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lista de ofertas en la página
|
||||||
|
container.components.push({
|
||||||
|
type: 10,
|
||||||
|
content: selectedOffer ? '📋 **Otras Ofertas:**' : '📋 **Ofertas Disponibles:**'
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const offer of pageOffers) {
|
||||||
|
const item = offer.item;
|
||||||
|
const props = parseItemProps(item.props);
|
||||||
|
const icon = getItemIcon(props, item.category);
|
||||||
|
const price = formatPrice(offer.price);
|
||||||
|
const isSelected = selectedOfferId === offer.id;
|
||||||
|
|
||||||
|
const stockText = offer.stock != null ? ` (${offer.stock} disponibles)` : '';
|
||||||
|
const selectedMark = isSelected ? ' ✓' : '';
|
||||||
|
|
||||||
|
container.components.push({
|
||||||
|
type: 9,
|
||||||
|
components: [{
|
||||||
|
type: 10,
|
||||||
|
content: `${icon} **${item.name || item.key}**${selectedMark}\n💰 ${price}${stockText}`
|
||||||
|
}],
|
||||||
|
accessory: {
|
||||||
|
type: 2,
|
||||||
|
style: isSelected ? ButtonStyle.Success : ButtonStyle.Primary,
|
||||||
|
label: isSelected ? 'Seleccionado' : 'Ver',
|
||||||
|
custom_id: `shop_view_${offer.id}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Botones de navegación y acciones
|
||||||
|
const actionRow1 = {
|
||||||
|
type: ComponentType.ActionRow,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.Button,
|
||||||
|
style: ButtonStyle.Secondary,
|
||||||
|
label: '◀️ Anterior',
|
||||||
|
custom_id: 'shop_prev_page',
|
||||||
|
disabled: safePage <= 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Button,
|
||||||
|
style: ButtonStyle.Secondary,
|
||||||
|
label: `Página ${safePage}/${totalPages}`,
|
||||||
|
custom_id: 'shop_current_page',
|
||||||
|
disabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Button,
|
||||||
|
style: ButtonStyle.Secondary,
|
||||||
|
label: 'Siguiente ▶️',
|
||||||
|
custom_id: 'shop_next_page',
|
||||||
|
disabled: safePage >= totalPages
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionRow2 = {
|
||||||
|
type: ComponentType.ActionRow,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: ComponentType.Button,
|
||||||
|
style: ButtonStyle.Success,
|
||||||
|
label: '🛒 Comprar (x1)',
|
||||||
|
custom_id: 'shop_buy_1',
|
||||||
|
disabled: !selectedOfferId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Button,
|
||||||
|
style: ButtonStyle.Success,
|
||||||
|
label: '🛒 Comprar (x5)',
|
||||||
|
custom_id: 'shop_buy_5',
|
||||||
|
disabled: !selectedOfferId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Button,
|
||||||
|
style: ButtonStyle.Primary,
|
||||||
|
label: '🔄 Actualizar',
|
||||||
|
custom_id: 'shop_refresh'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: ComponentType.Button,
|
||||||
|
style: ButtonStyle.Danger,
|
||||||
|
label: '❌ Cerrar',
|
||||||
|
custom_id: 'shop_close'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return [container, actionRow1, actionRow2];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleButtonInteraction(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
offers: any[],
|
||||||
|
currentPage: number,
|
||||||
|
selectedOfferId: string | null,
|
||||||
|
userId: string,
|
||||||
|
guildId: string,
|
||||||
|
shopMessage: Message,
|
||||||
|
collector: any
|
||||||
|
): Promise<void> {
|
||||||
|
const customId = interaction.customId;
|
||||||
|
|
||||||
|
// Ver detalles de un item
|
||||||
|
if (customId.startsWith('shop_view_')) {
|
||||||
|
const offerId = customId.replace('shop_view_', '');
|
||||||
|
const wallet = await getOrCreateWallet(userId, guildId);
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
components: await buildShopPanel(offers, currentPage, wallet.coins, offerId)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comprar
|
||||||
|
if (customId === 'shop_buy_1' || customId === 'shop_buy_5') {
|
||||||
|
if (!selectedOfferId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: '❌ Primero selecciona un item.',
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qty = customId === 'shop_buy_1' ? 1 : 5;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
const result = await buyFromOffer(userId, guildId, selectedOfferId, qty);
|
||||||
|
const wallet = await getOrCreateWallet(userId, guildId);
|
||||||
|
|
||||||
|
await interaction.followUp({
|
||||||
|
content: `✅ **Compra exitosa!**\n🛒 ${result.item.name || result.item.key} x${result.qty}\n💰 Te quedan: ${wallet.coins} monedas`,
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actualizar tienda
|
||||||
|
await shopMessage.edit({
|
||||||
|
components: await buildShopPanel(offers, currentPage, wallet.coins, selectedOfferId)
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
await interaction.followUp({
|
||||||
|
content: `❌ No se pudo comprar: ${error?.message ?? error}`,
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar
|
||||||
|
if (customId === 'shop_refresh') {
|
||||||
|
const wallet = await getOrCreateWallet(userId, guildId);
|
||||||
|
await interaction.update({
|
||||||
|
components: await buildShopPanel(offers, currentPage, wallet.coins, selectedOfferId)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cerrar
|
||||||
|
if (customId === 'shop_close') {
|
||||||
|
await interaction.update({
|
||||||
|
components: await buildClosedPanel()
|
||||||
|
});
|
||||||
|
collector.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navegación de páginas (ya manejado en el collect)
|
||||||
|
if (customId === 'shop_prev_page' || customId === 'shop_next_page') {
|
||||||
|
const wallet = await getOrCreateWallet(userId, guildId);
|
||||||
|
let newPage = currentPage;
|
||||||
|
|
||||||
|
if (customId === 'shop_prev_page') {
|
||||||
|
newPage = Math.max(1, currentPage - 1);
|
||||||
|
} else {
|
||||||
|
const totalPages = Math.ceil(offers.length / ITEMS_PER_PAGE);
|
||||||
|
newPage = Math.min(totalPages, currentPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
components: await buildShopPanel(offers, newPage, wallet.coins, selectedOfferId)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectInteraction(
|
||||||
|
interaction: StringSelectMenuInteraction,
|
||||||
|
offers: any[],
|
||||||
|
currentPage: number,
|
||||||
|
userId: string,
|
||||||
|
guildId: string,
|
||||||
|
shopMessage: Message
|
||||||
|
): Promise<void> {
|
||||||
|
// Si implementas un select menu, manejar aquí
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Select menu no implementado aún',
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildExpiredPanel(): Promise<any[]> {
|
||||||
|
const container: DisplayComponentContainer = {
|
||||||
|
type: 17,
|
||||||
|
accent_color: 0x36393f,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: '⏰ **Tienda Expirada**'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 14,
|
||||||
|
divider: true,
|
||||||
|
spacing: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: 'La sesión de tienda ha expirado.\nUsa `!tienda` nuevamente para ver las ofertas.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildClosedPanel(): Promise<any[]> {
|
||||||
|
const container: DisplayComponentContainer = {
|
||||||
|
type: 17,
|
||||||
|
accent_color: 0x36393f,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: '✅ **Tienda Cerrada**'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 14,
|
||||||
|
divider: true,
|
||||||
|
spacing: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: '¡Gracias por visitar la tienda!\nVuelve pronto. 🛒'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return [container];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user