35 Commits
v1.0.0 ... main

Author SHA1 Message Date
Resistencia Dev
c723b373d3 Feat: Add HelpModal and scrollable lobby list
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-27 23:22:01 +01:00
Resistencia Dev
a3789e5289 Docs: Update README to v1.4
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-27 23:05:15 +01:00
Resistencia Dev
b68f4e9ff5 Fix: Update socket ID refs on reconnect (leader buttons bug)
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-27 22:45:17 +01:00
Resistencia Dev
800db837bb docs: add .env.example template for configuration
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 5s
2025-12-23 00:04:41 +01:00
Resistencia Dev
69e1f35886 feat: make admin dashboard password configurable via env variable NEXT_PUBLIC_ADMIN_PASSWORD
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-23 00:00:03 +01:00
Resistencia Dev
98b5984a6b chore: bump version to 1.3.0
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-22 23:47:33 +01:00
Resistencia Dev
b0eb3bd637 feat: shuffle mission reveal cards randomly on each client
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-22 23:46:06 +01:00
Resistencia Dev
1a68ed2a5c feat(dashboard): track matches played per session instead of rounds in history
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-22 23:35:58 +01:00
Resistencia Dev
904bd80bd5 feat(dashboard): add matchNumber to track games played per session
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 7s
2025-12-22 23:24:07 +01:00
Resistencia Dev
c4c08c64c3 feat(dashboard): show round info and results for active and finished games
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 10s
2025-12-22 23:03:31 +01:00
Resistencia Dev
1ad4f46aa4 feat(dashboard): refactor to collapsible compact lists 2025-12-22 22:59:33 +01:00
Resistencia Dev
797780fc94 feat: update voting timer styles (centered, grayscale, larger)
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-22 22:13:53 +01:00
Resistencia Dev
77194bd8f6 chore: Bump version to 1.1.2
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-22 20:52:44 +01:00
Resistencia Dev
3ac48e50fb fix: Redirigir al lobby cuando la partida deja de existir tras recargar
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
- Añadido listener de errores de socket para detectar fallos de reconexión
- Actualizada la lógica de transiciones de vista para evitar bloqueos en pantallas vacías
- Limpieza de sesión al fallar la reconexión
2025-12-22 18:38:07 +01:00
Resistencia Dev
91da241423 feat: Dashboard de Admin 2.0 - Persistencia y Actualizaciones en Tiempo Real
- Implementar persistencia de sesión en Dashboard mediante localStorage
- Añadir botón de desconexión (Logout)
- Implementar sistema de broadcast para que el Dashboard se actualice automáticamente ante cualquier cambio en el servidor
- Mejorar diseño táctico del dashboard con visor de estadísticas rápidas
2025-12-22 18:24:25 +01:00
Resistencia Dev
1548309753 fix: Permitir múltiples orígenes CORS y añadir logs de admin
- Permitir conexiones desde localhost, 127.0.0.1 y la IP de red local
- Corregir error de compilación de 'pg' reconstruyendo la imagen
- Añadir logs para depuración del dashboard de administración
2025-12-22 18:12:33 +01:00
Resistencia Dev
3d68eddb8b feat: Implementar Dashboard de Administración y Historial de Partidas
- Crear Dashboard en /dashboard con protección por contraseña
- Integrar PostgreSQL para registro histórico de partidas (game_logs)
- Permitir forzar cierre de partidas y expulsar jugadores desde Dashboard
- Diseño premium e integración en tiempo real vía Sockets
2025-12-22 18:01:48 +01:00
Resistencia Dev
848eb0486d chore: Bump version to 1.1.0
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-22 17:55:51 +01:00
Resistencia Dev
ae9e268467 chore: Sincronizar docker-compose_prod.yml con el sistema de variables de entorno
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-22 17:55:12 +01:00
Resistencia Dev
2a9d89ed9e style: Hacer que el panel de jugadores aparezca plegado por defecto 2025-12-22 17:52:34 +01:00
Resistencia Dev
6e9f4512fb style: Hacer que el historial de misiones aparezca plegado por defecto 2025-12-22 17:50:08 +01:00
Resistencia Dev
12ac47e6c1 style: Ajustar posición del historial a 60px para alineación perfecta 2025-12-22 17:47:06 +01:00
Resistencia Dev
7895df4fd0 fix: Aumentar separación entre botones de salir e historial
- Cambiar posición del historial de top-[46px] a top-[56px]
- Evita solapamiento entre los botones
- Separación de ~12px entre ambos
2025-12-22 17:44:24 +01:00
Resistencia Dev
8835e780eb feat: Intercambiar posiciones de botones de historial y salir
- Botón de salir (casita verde) ahora en top-4 (arriba)
- Botón de historial movido a top-[46px] (debajo)
- Separación de 2px entre ambos botones
- Botones alineados verticalmente en la esquina superior derecha
2025-12-22 17:43:41 +01:00
Resistencia Dev
8dc01132e7 feat: Igualar estilo del botón de lobby al botón de historial
- Pegado a la derecha (right-0)
- Mismo tamaño (px-2 py-3, icono w-4 h-4)
- Gradiente verde (from-green-600 to-green-700)
- Borde redondeado solo a la izquierda (rounded-l-lg)
- Sin texto, solo icono de casita
- strokeWidth aumentado a 3 para mayor visibilidad
2025-12-22 17:41:36 +01:00
Resistencia Dev
6369421cb6 fix: Posicionar botón de lobby debajo del botón de historial
- Cambiar top-4 a top-20
- Ahora el botón verde de lobby está debajo del botón de historial
2025-12-22 17:38:58 +01:00
Resistencia Dev
060b604e04 feat: Mejorar diseño del botón de salir de la partida
- Mover botón de arriba-izquierda a arriba-derecha
- Cambiar color de amarillo a verde (bg-green-800)
- Cambiar icono de flecha por icono de casita (home)
- Cambiar texto de 'Salir' a 'Lobby'
- Actualizar tooltip a 'Volver al lobby'
2025-12-22 17:34:01 +01:00
Resistencia Dev
40c9de3388 fix: Configurar correctamente variables de entorno para acceso desde red local
- Agregar ARG en client/Dockerfile para NEXT_PUBLIC_API_URL
- Pasar build args en docker-compose.yml
- Asegurar que Next.js reciba la URL correcta del servidor
- Permitir acceso desde 192.168.1.131
2025-12-22 17:27:03 +01:00
Resistencia Dev
b1322e05c6 feat: Configurar acceso desde red local
- Crear archivo .env.example con configuración de red local
- Actualizar docker-compose.yml para usar variables de entorno
- Soportar acceso desde IP 192.168.1.131
- Documentar configuración en NETWORK-ACCESS.md
- Permitir acceso desde móviles y tablets en la misma red
2025-12-22 17:20:41 +01:00
Resistencia Dev
e9b4390db6 fix: Resolver bucle infinito de reconexiones
- Agregar bandera hasReconnected para ejecutar reconexión solo una vez
- Evita que el useEffect se ejecute infinitamente
- Reduce carga del servidor significativamente
2025-12-22 17:16:16 +01:00
Resistencia Dev
ef9d772441 revert: Restaurar tamaño original del botón de logout
- Padding vuelve a p-2
- Icono vuelve a w-5 h-5
- Tamaño más visible y usable
2025-12-22 17:13:23 +01:00
Resistencia Dev
5c7f52f793 fix: Reducir tamaño del botón de logout
- Padding reducido de p-2 a p-1.5
- Icono reducido de w-5 h-5 a w-4 h-4
- Botón más compacto y proporcional
2025-12-22 17:10:59 +01:00
Resistencia Dev
a6d1b11575 fix: Quitar posicionamiento fixed del botón de logout
- Ahora el botón se integra en el header del lobby
- Aparece al lado derecho del nombre del agente
- Ya no está arriba a la izquierda de forma fija
2025-12-22 17:09:17 +01:00
Resistencia Dev
1c03149bbd fix: Mejorar diseño del botón de logout
- Cambiar icono a power button (Heroicons)
- Mover botón debajo del nombre del agente en el lobby
- Hacer botón más compacto (circular)
- Mejorar layout del header del lobby
2025-12-22 17:08:07 +01:00
Resistencia Dev
53a5e3886e feat: Implementar sesiones persistentes y botones de salida
- Añadido sistema de sesiones con localStorage
- Nuevo hook useSessionStorage para manejar sesiones
- Botón de salir de la partida (ExitGameButton) en todas las pantallas del juego
- Botón de logout (LogoutButton) solo en el lobby
- Evento leave_game en servidor para cerrar partida cuando alguien sale
- Evento reconnect_session para reconectar jugadores después de recargar
- Actualizado GameBoard para incluir botón de salida
- Actualizado page.tsx para manejar sesiones y logout
2025-12-22 16:51:35 +01:00
26 changed files with 3662 additions and 161 deletions

View File

@@ -0,0 +1,62 @@
---
description: Implementación de sesiones persistentes y botones de salida
---
# Implementación de Sesiones y Botones de Salida
## Objetivo
Implementar tres mejoras principales:
1. Sistema de sesiones persistentes
2. Botón de salir de la partida (en todas las pantallas de juego)
3. Botón de salir del juego (solo en lobby)
## Tareas
### 1. Sistema de Sesiones Persistentes
**Cliente:**
- Crear hook `useSessionStorage` para manejar sesiones
- Guardar en localStorage:
- `playerName` y `fullPlayerName`
- `currentView` (login/lobby/game)
- `roomId` si está en una partida
- Al cargar la app, verificar si hay sesión activa
- Si hay sesión, reconectar al servidor y recuperar estado
**Servidor:**
- Implementar evento `reconnect_session` para validar y recuperar estado
- Mantener mapping de `socketId` a `playerId` persistente
- Al reconectar, actualizar el socketId del jugador en la partida
### 2. Botón de Salir de la Partida
**Cliente:**
- Crear componente `ExitGameButton` con icono de flecha
- Posicionar arriba a la izquierda
- Mostrar en todas las fases del juego (no en lobby)
- Al hacer clic, emitir evento `leave_game`
**Servidor:**
- Implementar evento `leave_game`
- Notificar a todos los jugadores que alguien salió
- Eliminar la partida de la BD
- Devolver a todos al lobby
### 3. Botón de Salir del Juego
**Cliente:**
- Crear componente `LogoutButton` con icono de apagar
- Posicionar arriba a la izquierda solo en lobby
- Al hacer clic:
- Limpiar localStorage
- Volver a vista de login
- Desconectar socket si está en partida
## Orden de Implementación
1. Crear hooks y utilidades para sesiones
2. Implementar botón de salir del juego (logout)
3. Implementar botón de salir de la partida
4. Implementar lógica de reconexión en servidor
5. Integrar sistema de sesiones en el cliente
6. Pruebas

25
.env.example Normal file
View File

@@ -0,0 +1,25 @@
# ===========================================
# Archivo de ejemplo de configuración
# Copia este archivo a .env y modifica los valores
# ===========================================
# Configuración de red local
# Cambia esta IP a la IP de tu PC en la red local
HOST_IP=192.168.1.XXX
# URLs para desarrollo local
NEXT_PUBLIC_API_URL=http://192.168.1.XXX:4000
CORS_ORIGIN=http://192.168.1.XXX:3000
# URLs para producción (descomentar y ajustar)
# NEXT_PUBLIC_API_URL=https://api.tudominio.com
# CORS_ORIGIN=https://tudominio.com
# Configuración de base de datos
DATABASE_URL=postgresql://postgres:password@db:5432/resistencia
POSTGRES_USER=postgres
POSTGRES_PASSWORD=cambia_esta_contraseña
POSTGRES_DB=resistencia
# Contraseña del dashboard de administración
NEXT_PUBLIC_ADMIN_PASSWORD=cambia_esta_contraseña

62
NETWORK-ACCESS.md Normal file
View File

@@ -0,0 +1,62 @@
# Configuración de Acceso desde Red Local
## Problema
Por defecto, la aplicación está configurada para usar `localhost`, lo que solo permite acceso desde el mismo PC. Para acceder desde otros dispositivos (móviles, tablets, etc.) en la misma red local, necesitas configurar la IP de tu PC.
## Solución
### 1. Obtener la IP de tu PC
```bash
ip -c a
```
Busca la IP en la interfaz de red activa (ej: `enp3s0`). En este caso: `192.168.1.131`
### 2. Configurar el archivo .env
Edita el archivo `.env` en la raíz del proyecto:
```env
# Cambia esta IP a la IP de tu PC
HOST_IP=192.168.1.131
NEXT_PUBLIC_API_URL=http://192.168.1.131:4000
CORS_ORIGIN=http://192.168.1.131:3000
```
### 3. Reiniciar Docker
```bash
docker compose down
docker compose up -d --build
```
### 4. Acceder desde otros dispositivos
Desde tu móvil, tablet u otro dispositivo en la misma red WiFi, abre el navegador y ve a:
```
http://192.168.1.131:3000
```
## Notas Importantes
- **Firewall**: Asegúrate de que tu firewall permita conexiones en los puertos 3000 y 4000
- **Misma red**: Todos los dispositivos deben estar en la misma red WiFi/LAN
- **IP dinámica**: Si tu IP cambia (DHCP), tendrás que actualizar el archivo `.env` y reiniciar Docker
## Verificar Firewall (Ubuntu/Linux)
```bash
# Ver estado del firewall
sudo ufw status
# Si está activo, permitir los puertos
sudo ufw allow 3000/tcp
sudo ufw allow 4000/tcp
```
## Para volver a localhost
Simplemente edita `.env` y cambia las URLs a `localhost`:
```env
NEXT_PUBLIC_API_URL=http://localhost:4000
CORS_ORIGIN=http://localhost:3000
```
Y reinicia Docker.

141
README.md
View File

@@ -1,104 +1,91 @@
# La Resistencia - Estado del Proyecto
# Francia Ocupada (La Resistencia)
## 🎮 Estado Actual: FUNCIONAL (Parcial)
## 🎮 Estado Actual: v1.4 - ESTABLE
Proyecto renombrado a **"Traición en París"** / **"Francia Ocupada"**.
### ✅ Funcionalidades Implementadas
#### Backend (100% Completo)
- ✅ Sistema de salas y jugadores
-Asignación de roles (Resistencia/Espías)
-Fases del juego: LOBBY, INTRO, REVEAL_ROLE, ROLL_CALL, VOTE_LEADER, TEAM_BUILDING, VOTE_TEAM, MISSION
-**Nuevas fases**: MISSION_REVEAL, MISSION_RESULT (lógica completa)
- ✅ Votación de líder con sistema de aprobación/rechazo
- ✅ Selección y votación de equipos
- ✅ Votación de misiones (éxito/sabotaje)
- **Barajado aleatorio de votos de misión**
-**Histórico de misiones** (MissionRecord)
- ✅ Reglas correctas de éxito/fracaso (misión 4 con 7+ jugadores requiere 2 fallos)
- ✅ Sistema de victoria (3 misiones exitosas o 3 fallidas)
- ✅ Fase de asesinato (ASSASSIN_PHASE)
#### Backend (Socket.io + Node.js)
-**Sistema de Reconexión Inteligente:** Los jugadores pueden recargar la página o volver a entrar y mantienen su sesión (ID de socket actualizado en tiempo real).
-**Persistencia de Partidas:** Las partidas no se pierden si un jugador se desconecta brevemente.
-**Control de Sesiones:** Seguimiento de `matchNumber` y `matchResults` para estadísticas continuas en el dashboard de admin.
-**Lógica de Juego Completa:**
- Roles ocultos y asignación aleatoria balanceada.
- Fases: Lobby, Intro, Reveal Role, Roll Call, Vote Leader, Team Building, Vote Team, Mission, Mission Reveal, Mission Result, Assassin Phase.
- Reglas especiales: 4ª misión con 7+ jug. requiere 2 fallos, 5 rechazos de líder = Victoria espía.
- Temporizadores de seguridad para votaciones de líder.
#### Frontend (Funcional hasta Victoria)
-Lobby con creación/unión de partidas
-Intro con música y animaciones (título: "Traidores en París")
-Revelación de roles con cartas
- ✅ Roll call con avatares
- ✅ Votación de líder con timer de 10 segundos (esquina superior izquierda)
- ✅ Selección de equipo por el líder
-Votación de equipo con cartas cuadradas (aceptar/rechazar)
- ✅ Votación de misión (éxito/sabotaje solo para espías)
-**Fase MISSION_REVEAL**: Animación de cartas de votación revelándose
-**Fase MISSION_RESULT**: Pantalla resumen con mapa táctico y tokens de victoria/fracaso
-**Histórico de Misiones**: Círculos clicables en esquina superior derecha
-**Mapa táctico**: Tablero con tokens de misiones y marcadores de resultado
- ✅ Pantallas de victoria (Aliados/Nazis)
- ✅ Fase de asesinato (ASSASSIN_PHASE)
#### Frontend (Next.js + Tailwind + Framer Motion)
-**Diseño Premium:** Estética "World War II" con tipografías, colores y assets temáticos (fichas de póker, documentos secretos, mapas tácticos).
-**UX Fluida:** Animaciones de transición entre todas las fases.
-**Feedback Visual:**
- Cartas de rol específicas para cada personaje (Marlene, Capitán Philippe, Francotirador, etc.).
- Tablero táctico dinámico que muestra el progreso de la partida.
- Indicadores claros de "Tu Turno" o "Esperando al líder".
-**Audio:** Banda sonora y efectos de sonido integrados.
### 🎨 Mejoras de UI Recientes (2025-12-13)
### 🎨 Últimas Mejoras (v1.4 - Diciembre 2025)
- ✅ Timer de votación reposicionado (esquina superior izquierda, fixed, 20px margen)
- ✅ Cartas de votación de líder redimensionadas (cuadradas 32x32)
- ✅ Eliminado contador de votos rechazados en resultado de misión
- ✅ Mapa táctico permanece visible durante toda la fase MISSION_RESULT
- ✅ Tokens de victoria/fracaso posicionados en el mapa (ajuste fino pendiente para tokens 3, 4, 5)
- 🔄 **Fix Crítico:** Solucionado bug donde el Líder perdía sus botones de acción al recargar la página.
- 📊 **Dashboard Admin:** Panel de administración mejorado con historial de partidas y estadísticas de victorias.
- 🖼️ **Assets:** Nuevas imágenes para roles y fondos de victoria/derrota.
- 📱 **Responsividad:** Mejoras en la vista móvil para la fase de "Pasando Lista" y Tablero.
### ⚠️ Pendiente de Ajustar
## 🚀 Despliegue y Ejecución
1. **Posiciones de tokens en mapa**: Afinar ubicación de tokens de misiones 3, 4 y 5 en el tablero táctico
## 🚀 Cómo Ejecutar
### Requisitos
- Docker y Docker Compose
- Node.js 18+ (para desarrollo local)
### Ejecución con Docker (Producción)
```bash
# Iniciar todos los servicios
docker compose up -d
# Desplegar todo el stack
./deploy.sh
```
# Ver logs
docker compose logs -f
El script `deploy.sh` se encarga de:
1. Bajar los últimos cambios de `main`.
2. Construir las imágenes con tags de versión.
3. Levantar los contenedores (Client, Server, DB).
# Detener servicios
docker compose down
### Desarrollo Local
```bash
# Servidor
cd server && npm run dev
# Cliente
cd client && npm run dev
```
## 🌐 URLs
- **Cliente**: http://localhost:3000
- **Servidor**: http://localhost:4000
- **Base de Datos**: localhost:5432
- **Juego**: https://franciaocupada.martivich.es
- **API**: https://api.franciaocupada.martivich.es
- **Dashboard Admin**: (Accesible vía socket evento `admin_get_data`)
## 📝 Git
## 📝 Ramas Git
### Ramas
- `master`: Estado inicial con errores
- `fix-gameboard`: Estado actual funcional ✅
- `main`: Rama de producción (Estable v1.4).
- `develop`: Rama de desarrollo e integración.
### Commits Importantes
1. `8d423ac` - Estado inicial con errores de sintaxis
2. `44d7418` - GameBoard limpio y funcional
3. `5bb1b17` - VotingTimer agregado correctamente
## 👥 Roles del Juego
## 🐛 Problemas Conocidos
**La Resistencia (Aliados)**
- **Marlene (Merlín):** Conoce a los espías. Debe mantenerse oculta.
- **Capitán Philippe (Percival):** Conoce a Marlene (y al Agente Doble). Debe protegerla.
- **Partisanos:** Miembros leales de la resistencia. No saben nada.
1. **CPU Alta en Servidor**: El servidor puede consumir mucha CPU. Si ocurre, reiniciar con `docker compose restart server`
2. **Posiciones de tokens en mapa**: Los tokens de misiones 3, 4 y 5 necesitan ajuste fino en sus coordenadas
**El Eje (Alemanes/Espías)**
- **Francotirador (Asesino):** Si los aliados ganan las 3 misiones, tiene un disparo final para matar a Marlene y robar la victoria.
- **Agente Doble (Morgana):** Se hace pasar por Marlene ante el Capitán Philippe.
- **Comandante Schmidt (Mordred):** Espía desconocido para Marlene.
- **Infiltrado (Oberon):** Espía que no conoce a los otros espías.
- **Colaboricionistas:** Espías estándar.
## 📋 Próximos Pasos
1. Afinar posiciones de tokens de misiones 3, 4 y 5 en el mapa táctico
2. Optimizar rendimiento del servidor
3. Testing exhaustivo de todas las fases del juego
4. Ajustes finales de UX/UI según feedback de jugadores
## 🎯 Reglas del Juego Implementadas
- Votación de líder: mayoría simple aprueba
- 5 rechazos consecutivos: victoria de espías
- Misiones: 1 fallo = fracaso (excepto misión 4 con 7+ jugadores que requiere 2 fallos)
- 3 misiones exitosas: victoria de resistencia (con fase de asesinato)
- 3 misiones fallidas: victoria de espías
## 🔧 Tecnologías
## 🔧 Stack Tecnológico
- **Frontend**: Next.js 14, React, TypeScript, Framer Motion, TailwindCSS
- **Backend**: Node.js, Express, Socket.IO, TypeScript
- **Base de Datos**: PostgreSQL 15
- **Containerización**: Docker, Docker Compose
- **Infraestructura**: Docker, Nginx (Proxy Inverso)

112
SESIONES-IMPLEMENTACION.md Normal file
View File

@@ -0,0 +1,112 @@
# Resumen de Implementación: Sesiones y Botones de Salida
## Cambios Realizados
### 1. Sistema de Sesiones Persistentes ✅
#### Cliente
- **Nuevo hook**: `client/src/hooks/useSessionStorage.ts`
- Maneja el almacenamiento y recuperación de sesiones en localStorage
- Guarda: playerName, fullPlayerName, currentView, roomId
- **Actualización de `page.tsx`**:
- Integrado hook `useSessionStorage`
- Al iniciar sesión, se guarda la sesión
- Al cargar la app, se restaura la sesión si existe
- Al cambiar de vista (lobby/game), se actualiza la sesión
- Función `handleLogout` para limpiar sesión
#### Servidor
- **Nuevo evento**: `reconnect_session`
- Permite a un jugador reconectarse a una partida existente
- Actualiza el socketId del jugador en la partida
- Envía el estado actualizado al jugador reconectado
### 2. Botón de Salir de la Partida ✅
#### Cliente
- **Nuevo componente**: `client/src/components/ExitGameButton.tsx`
- Botón con icono de flecha
- Posicionado arriba a la izquierda (fixed top-4 left-4)
- Modal de confirmación antes de salir
- Se muestra en todas las fases del juego excepto en pantallas de victoria
- **Actualización de `GameBoard.tsx`**:
- Agregado prop `fullPlayerName`
- Integrado `ExitGameButton`
- Llama a `actions.leaveGame()` al confirmar
- **Actualización de `useSocket.ts`**:
- Nueva acción: `leaveGame()`
- Nuevo listener: `player_left_game`
#### Servidor
- **Nuevo evento**: `leave_game`
- Notifica a todos los jugadores que alguien abandonó
- Elimina la partida de la base de datos
- Limpia timers asociados
- Actualiza la lista de salas
- Desconecta a todos los jugadores de la sala
### 3. Botón de Salir del Juego (Logout) ✅
#### Cliente
- **Nuevo componente**: `client/src/components/LogoutButton.tsx`
- Botón con icono de apagar
- Posicionado arriba a la izquierda (fixed top-4 left-4)
- Solo visible en el lobby
- **Actualización de `page.tsx`**:
- Función `handleLogout()`:
- Limpia la sesión de localStorage
- Vuelve a la vista de login
- Si está en una partida, llama a `leaveGame()`
## Flujo de Uso
### Sesiones Persistentes
1. Usuario se loguea → sesión guardada en localStorage
2. Usuario cierra navegador
3. Usuario vuelve a abrir → sesión restaurada automáticamente
4. Si estaba en una partida, intenta reconectar
### Salir de la Partida
1. Usuario hace clic en botón de flecha (arriba izquierda)
2. Aparece modal de confirmación
3. Al confirmar:
- Servidor notifica a todos: "Jugador X ha abandonado"
- Partida eliminada de la BD
- Todos vuelven al lobby
### Salir del Juego (Logout)
1. Usuario hace clic en botón de apagar (arriba izquierda, solo en lobby)
2. Sesión eliminada de localStorage
3. Vuelve a pantalla de login
## Archivos Modificados
### Nuevos Archivos
- `client/src/hooks/useSessionStorage.ts`
- `client/src/components/LogoutButton.tsx`
- `client/src/components/ExitGameButton.tsx`
- `.agent/workflows/sesiones-y-botones.md`
### Archivos Modificados
- `client/src/app/page.tsx`
- `client/src/components/GameBoard.tsx`
- `client/src/hooks/useSocket.ts`
- `server/src/index.ts`
## Próximos Pasos
1. **Probar la aplicación**:
- Verificar que las sesiones persisten correctamente
- Probar el botón de salir de la partida
- Probar el botón de logout
- Verificar reconexión después de recargar
2. **Posibles mejoras**:
- Agregar notificación toast cuando alguien abandona
- Mejorar el manejo de errores en reconexión
- Agregar timeout de sesión (expiración automática)
- Guardar más información en la sesión (configuración, preferencias)

View File

@@ -1,5 +1,13 @@
FROM node:20-alpine
# Build arguments
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
ARG NEXT_PUBLIC_ADMIN_PASSWORD=admin123
# Make args available as env vars during build
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_ADMIN_PASSWORD=$NEXT_PUBLIC_ADMIN_PASSWORD
# Create app directory
WORKDIR /app

View File

@@ -1,6 +1,6 @@
{
"name": "resistencia-client",
"version": "1.0.0",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "next dev",

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,456 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useSocket } from '../../hooks/useSocket';
import { Shield, Users, Gamepad2, LogOut, Clock, History, UserMinus, Key, ChevronDown } from 'lucide-react';
const ADMIN_PASSWORD = process.env.NEXT_PUBLIC_ADMIN_PASSWORD || "admin123";
export default function Dashboard() {
const { socket, isConnected } = useSocket();
const [password, setPassword] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [activeGames, setActiveGames] = useState<any[]>([]);
const [gameHistory, setGameHistory] = useState<any[]>([]);
const [error, setError] = useState('');
const [expandedGames, setExpandedGames] = useState<Set<string>>(new Set());
// Comprobar sesión al cargar
useEffect(() => {
const savedSession = localStorage.getItem('resistencia_admin_session');
if (savedSession === 'active') {
setIsAuthenticated(true);
}
}, []);
// Cargar datos y escuchar actualizaciones en tiempo real
useEffect(() => {
if (isAuthenticated && socket) {
// Solicitar datos iniciales
socket.emit('admin_get_data');
// Escuchar actualizaciones (el servidor emite a admin-room)
socket.on('admin_data', (data: any) => {
console.log('[ADMIN] Datos recibidos:', data);
setActiveGames(data.activeGames);
setGameHistory(data.history);
});
socket.on('admin_action_success', () => {
console.log('[ADMIN] Acción realizada con éxito');
socket.emit('admin_get_data');
});
return () => {
socket.off('admin_data');
socket.off('admin_action_success');
};
}
}, [isAuthenticated, socket]);
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
if (password === ADMIN_PASSWORD) {
setIsAuthenticated(true);
localStorage.setItem('resistencia_admin_session', 'active');
setError('');
} else {
setError('Acceso Denegado: Contraseña incorrecta');
}
};
const handleLogout = () => {
localStorage.removeItem('resistencia_admin_session');
setIsAuthenticated(false);
setPassword('');
};
const closeGame = (roomId: string) => {
if (confirm('¿Seguro que quieres forzar el cierre de esta partida?')) {
socket?.emit('admin_close_game', { roomId });
}
};
const kickPlayer = (roomId: string, socketId: string) => {
if (confirm('¿Seguro que quieres expulsar a este jugador?')) {
socket?.emit('admin_kick_player', { roomId, targetSocketId: socketId });
}
};
const toggleGame = (id: string) => {
setExpandedGames(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
return newSet;
});
};
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-[#0a0a0c] flex items-center justify-center p-4 font-['Inter',sans-serif]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-[#121216] border border-white/10 p-10 rounded-3xl w-full max-w-md shadow-[0_20px_50px_rgba(0,0,0,0.5)] relative overflow-hidden"
>
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-red-600 via-zinc-800 to-red-600"></div>
<div className="flex justify-center mb-8 relative">
<div className="absolute inset-0 bg-red-500/20 blur-2xl rounded-full"></div>
<Shield size={64} className="text-red-500 relative z-10" />
</div>
<h1 className="text-3xl font-black text-white text-center mb-2 uppercase tracking-tight">Acceso Admin</h1>
<p className="text-gray-400 text-center text-sm mb-10 font-medium">Panel de Control de La Resistencia</p>
<form onSubmit={handleLogin} className="space-y-6">
<div className="relative">
<Key size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Introduce la contraseña"
className="w-full bg-[#1a1a20] border border-white/5 rounded-2xl pl-12 pr-6 py-4 text-white focus:outline-none focus:ring-2 focus:ring-red-600/50 transition-all placeholder:text-gray-600 font-medium"
/>
</div>
{error && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-red-500 text-xs font-bold text-center mt-2 flex items-center justify-center gap-2"
>
<span className="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>
{error}
</motion.p>
)}
<button
type="submit"
className="w-full bg-red-600 hover:bg-red-500 text-white font-black py-4 rounded-2xl shadow-xl shadow-red-600/10 transition-all uppercase tracking-[0.2em] text-xs hover:scale-[1.02] active:scale-[0.98]"
>
Entrar en Sistema
</button>
</form>
</motion.div>
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0c] text-white p-4 md:p-10 font-['Inter',sans-serif]">
{/* Header */}
<header className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-start md:items-center mb-12 gap-6">
<div className="space-y-1">
<div className="flex items-center gap-3 text-red-500">
<div className="p-2 bg-red-500/10 rounded-lg">
<Shield size={22} />
</div>
<span className="text-[10px] font-black uppercase tracking-[0.4em] opacity-80">Seguimiento de Operaciones</span>
</div>
<h1 className="text-5xl font-black tracking-tighter uppercase italic text-transparent bg-clip-text bg-gradient-to-r from-white via-white to-gray-500">Comandante</h1>
</div>
<div className="flex items-center gap-6 bg-[#121216] border border-white/10 px-8 py-4 rounded-3xl shadow-xl">
<div className="flex flex-col items-end">
<span className="text-[10px] text-gray-500 uppercase font-black tracking-widest mb-1">Status Servidor</span>
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${isConnected ? 'bg-green-500 shadow-[0_0_15px_rgba(34,197,94,0.6)] animate-pulse' : 'bg-red-500'}`}></div>
<span className="text-xs font-mono font-black tracking-widest">{isConnected ? 'OPERATIVO' : 'DESCONECTADO'}</span>
</div>
</div>
<div className="w-px h-8 bg-white/10 mx-2"></div>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-5 py-2.5 bg-zinc-800 hover:bg-red-600 text-gray-300 hover:text-white rounded-xl transition-all font-black text-[10px] uppercase tracking-widest group"
>
<LogOut size={16} className="group-hover:translate-x-1 transition-transform" />
Desconectar
</button>
</div>
</header>
<main className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-10">
{/* Panel Latino: Estadísticas Rápidas */}
<div className="lg:col-span-12 grid grid-cols-2 md:grid-cols-4 gap-4 mb-2">
{[
{ label: 'Partidas Activas', value: activeGames.length, color: 'text-red-500', icon: Gamepad2 },
{ label: 'Agentes Online', value: activeGames.reduce((acc, g) => acc + g.currentPlayers, 0), color: 'text-blue-400', icon: Users },
{ label: 'Misiones Registradas', value: gameHistory.length, color: 'text-orange-400', icon: History },
{ label: 'Uso de CPU', value: '4%', color: 'text-green-400', icon: Clock }
].map((stat, i) => (
<div key={i} className="bg-[#121216] border border-white/5 p-6 rounded-2xl flex items-center justify-between">
<div>
<p className="text-[10px] uppercase font-black tracking-widest text-gray-500 mb-1">{stat.label}</p>
<p className={`text-2xl font-black ${stat.color}`}>{stat.value}</p>
</div>
<stat.icon className="opacity-10" size={32} />
</div>
))}
</div>
{/* Columna Principal: Partidas Activas */}
<div className="lg:col-span-8 space-y-8">
<section>
<div className="flex items-center gap-4 mb-8">
<h2 className="text-xl font-black text-white uppercase tracking-tighter italic border-l-4 border-red-600 pl-4">Canales de Radio Activos</h2>
</div>
<div className="grid gap-4">
<AnimatePresence mode="popLayout">
{activeGames.length === 0 ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="bg-[#121216]/40 border border-dashed border-white/10 rounded-3xl p-20 text-center"
>
<Clock size={48} className="mx-auto mb-6 text-gray-700" />
<p className="text-lg font-bold text-gray-600 uppercase tracking-widest">Silencio Radioeléctrico</p>
<p className="text-xs text-gray-700 font-medium">Buscando señales de misiones activas...</p>
</motion.div>
) : (
activeGames.map((game: any) => (
<motion.div
key={game.id}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-[#121216] border border-white/10 rounded-3xl overflow-hidden hover:bg-[#16161c] transition-all group shadow-lg"
>
{/* Cabecera Compacta (Clickable) */}
<div
onClick={() => toggleGame(game.id)}
className="flex flex-wrap items-center justify-between p-6 cursor-pointer gap-4"
>
<div className="flex items-center gap-4">
<div className="w-2 h-12 bg-red-600 rounded-full" />
<div>
<h3 className="text-xl font-black text-white italic uppercase tracking-tighter leading-none mb-1">{game.name}</h3>
<div className="flex items-center gap-2 text-[10px] uppercase font-black tracking-widest text-gray-500">
<span>ID: {game.id.slice(0, 6)}</span>
<span className="text-gray-700"></span>
<span>{new Date(game.created_at || Date.now()).toLocaleTimeString()}</span>
</div>
</div>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 bg-black/40 px-3 py-1.5 rounded-lg border border-white/5">
<Users size={14} className="text-gray-400" />
<span className="text-xs font-bold text-white">{game.currentPlayers} / {game.maxPlayers}</span>
</div>
<div className={`px-3 py-1 rounded text-[10px] font-black uppercase tracking-widest border ${game.status === 'WAITING' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' : 'bg-green-500/10 text-green-500 border-green-500/20'}`}>
{game.status}
</div>
{game.currentRound > 0 && (
<div className="bg-white/10 px-3 py-1 rounded text-[10px] font-black uppercase tracking-widest border border-white/10">
{game.matchNumber > 1 ? `P${game.matchNumber} - R${game.currentRound}` : `Ronda ${game.currentRound}`}
</div>
)}
<ChevronDown
size={20}
className={`text-gray-500 transition-transform duration-300 ${expandedGames.has(game.id) ? 'rotate-180' : ''}`}
/>
</div>
</div>
{/* Contenido Expandible */}
<AnimatePresence>
{expandedGames.has(game.id) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-white/5 bg-black/20"
>
<div className="p-6 pt-2">
{/* Panel de Control */}
<div className="flex justify-end mb-6">
<button
onClick={(e) => { e.stopPropagation(); closeGame(game.id); }}
className="px-6 py-3 bg-zinc-900 hover:bg-red-900/40 text-gray-400 hover:text-red-400 rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] transition-all border border-white/5 hover:border-red-500/30 active:scale-95 whitespace-nowrap flex items-center gap-2"
>
<LogOut size={14} />
Abortar Misión
</button>
</div>
{/* Grid de Jugadores */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{game.players.map((player: any) => (
<div key={player.id} className="bg-[#0a0a0c] p-3 rounded-xl border border-white/5 flex items-center justify-between group/player hover:border-white/10 transition-all">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center font-black text-gray-400 text-xs border border-white/5 group-hover/player:text-white transition-all">
{player.name[0].toUpperCase()}
</div>
<div className="overflow-hidden">
<p className="text-xs font-bold text-gray-300 truncate">{player.name}</p>
<p className="text-[9px] opacity-30 font-mono">AG-{player.id.slice(0, 4)}</p>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); kickPlayer(game.id, player.id); }}
className="p-2 text-gray-700 hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-all opacity-0 group-hover/player:opacity-100"
title="Expulsar"
>
<UserMinus size={14} />
</button>
</div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))
)}
</AnimatePresence>
</div>
</section>
</div>
{/* Columna Lateral: Archivo Histórico */}
<div className="lg:col-span-4 space-y-8">
<section className="sticky top-10">
<div className="flex items-center gap-4 mb-8">
<h2 className="text-xl font-black text-white uppercase tracking-tighter italic border-l-4 border-gray-600 pl-4">Informe Forense</h2>
</div>
<div className="bg-[#121216] border border-white/10 rounded-3xl overflow-hidden shadow-2xl">
<div className="p-6 bg-white/5 border-b border-white/5 flex justify-between items-center">
<p className="text-[10px] uppercase font-black tracking-widest text-gray-400">Archivos Recientes</p>
<History size={14} className="text-gray-600" />
</div>
<div className="max-h-[60vh] overflow-y-auto custom-scrollbar p-0">
{gameHistory.length === 0 ? (
<div className="text-center py-20 opacity-10">
<p className="text-xs italic font-bold">Sin registros</p>
</div>
) : (
gameHistory.map((entry: any) => (
<div key={entry.id} className="border-b border-white/5 last:border-0 last:rounded-b-3xl">
{/* Cabecera Historial */}
<div
onClick={() => toggleGame(entry.id)}
className={`p-4 cursor-pointer hover:bg-white/5 transition-colors flex justify-between items-center group relative overflow-hidden ${expandedGames.has(entry.id) ? 'bg-white/[0.02]' : ''}`}
>
<div className={`absolute left-0 top-0 bottom-0 w-1 transition-all ${entry.winner === 'resistance' ? 'bg-blue-500' : entry.winner === 'spies' ? 'bg-red-500' : 'bg-gray-600'} ${expandedGames.has(entry.id) ? 'opacity-100' : 'opacity-40 group-hover:opacity-100'}`} />
<div className="pl-3">
<h4 className="text-sm font-black text-gray-200 uppercase tracking-tight leading-none mb-1">{entry.room_name}</h4>
<div className="flex items-center gap-2 text-[9px] text-gray-600 font-bold uppercase tracking-widest">
<span>{entry.host_name}</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`text-[9px] font-black uppercase px-2 py-1 rounded ${entry.winner === 'resistance' ? 'bg-blue-500/10 text-blue-500' :
entry.winner === 'spies' ? 'bg-red-500/10 text-red-500' : 'bg-gray-700/20 text-gray-500'
}`}>
{entry.winner ? (entry.winner === 'resistance' ? 'ALIAD' : 'AXIS') : '??'}
</div>
<ChevronDown
size={14}
className={`text-gray-600 transition-transform ${expandedGames.has(entry.id) ? 'rotate-180' : ''}`}
/>
</div>
</div>
{/* Detalles Historial Expandido */}
<AnimatePresence>
{expandedGames.has(entry.id) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="bg-black/20"
>
<div className="p-4 pl-7 text-[10px] text-gray-400 space-y-2 font-mono border-t border-white/5">
<div className="flex justify-between">
<span>FECHA:</span>
<span className="text-gray-300">{new Date(entry.created_at).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span>HORA:</span>
<span className="text-gray-300">{new Date(entry.created_at).toLocaleTimeString()}</span>
</div>
<div className="flex justify-between">
<span>AGENTES:</span>
<span className="text-gray-300">{entry.players.split(',').length}</span>
</div>
{entry.matches_played > 0 && (
<>
<div className="flex justify-between">
<span>PARTIDAS:</span>
<span className="text-gray-300">{entry.matches_played}</span>
</div>
{entry.match_results && entry.match_results.length > 0 && (
<div className="flex justify-between items-center pt-2">
<span>RESULTADOS:</span>
<div className="flex gap-1">
{entry.match_results.split(',').map((res: string, idx: number) => (
<div
key={idx}
className={`w-3 h-3 rounded-full ${res === 'aliados' ? 'bg-blue-500' : 'bg-red-500'}`}
title={res === 'aliados' ? 'Victoria Aliados' : 'Victoria Nazis'}
/>
))}
</div>
</div>
)}
</>
)}
{entry.players && (
<div className="pt-2 border-t border-white/5 mt-2">
<p className="mb-1 opacity-50">PARTICIPANTES:</p>
<p className="text-gray-500 leading-relaxed">{entry.players.split(',').join(', ')}</p>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))
)}
</div>
</div>
</section>
</div>
</main>
<style jsx global>{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
body {
background-color: #0a0a0c;
cursor: crosshair;
}
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.1);
}
`}</style>
</div>
);
}

View File

@@ -2,9 +2,12 @@
import { useState, useEffect } from 'react';
import { useSocket } from '../hooks/useSocket';
import { useSessionStorage } from '../hooks/useSessionStorage';
import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image';
import GameBoard from '../components/GameBoard';
import LogoutButton from '../components/LogoutButton';
import HelpModal from '../components/HelpModal';
import { GameRoom } from '../../../shared/types';
// Constantes de apellidos
@@ -21,6 +24,7 @@ type ViewState = 'login' | 'lobby' | 'game';
export default function Home() {
const { isConnected, gameState, roomsList, actions, socket } = useSocket();
const { session, saveSession, updateSession, clearSession } = useSessionStorage();
// Estados locales de UI
const [view, setView] = useState<ViewState>('login');
@@ -31,32 +35,94 @@ export default function Home() {
// UI Create/Join
const [showCreateModal, setShowCreateModal] = useState(false);
const [createConfig, setCreateConfig] = useState({ maxPlayers: 5, password: '' });
const [showHelp, setShowHelp] = useState(false);
const [passwordPromptRoomId, setPasswordPromptRoomId] = useState<string | null>(null);
const [joinPassword, setJoinPassword] = useState('');
const [hasReconnected, setHasReconnected] = useState(false);
// Restaurar sesión al cargar - SOLO UNA VEZ
useEffect(() => {
if (session && isConnected && !hasReconnected) {
setPlayerName(session.playerName);
setFullPlayerName(session.fullPlayerName);
setView(session.currentView);
// Si había una partida activa, intentar reconectar
if (session.roomId && session.currentView === 'game') {
actions.reconnectSession({ playerName: session.fullPlayerName, roomId: session.roomId });
} else if (session.currentView === 'lobby') {
actions.refreshRooms();
}
setHasReconnected(true);
}
}, [session, isConnected, hasReconnected]);
// Efecto para cambiar a vista de juego cuando el servidor nos une
useEffect(() => {
if (gameState?.roomId) {
setView('game');
// Guardar en sesión
updateSession({ currentView: 'game', roomId: gameState.roomId });
} else if (view === 'game' && !gameState) {
// Si estábamos en juego y volvemos a null, volver al lobby
setView('lobby');
// Pero solo si no estamos esperando una reconexión inicial
if (hasReconnected) {
setView('lobby');
updateSession({ currentView: 'lobby', roomId: undefined });
}
}
}, [gameState]);
}, [gameState, view, hasReconnected]);
// Listener para errores de socket que deben expulsar al lobby
useEffect(() => {
if (!socket) return;
const handleError = (msg: string) => {
if (msg === 'La partida ya no existe' || msg === 'No se pudo reconectar a la partida') {
setView('lobby');
updateSession({ currentView: 'lobby', roomId: undefined });
}
};
socket.on('error', handleError);
return () => {
socket.off('error', handleError);
};
}, [socket, updateSession]);
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
if (playerName) {
// Generar apellido aleatorio
const randomSurname = SURNAMES[Math.floor(Math.random() * SURNAMES.length)];
setFullPlayerName(`${playerName} ${randomSurname}`);
const fullName = `${playerName} ${randomSurname}`;
setFullPlayerName(fullName);
// Guardar sesión
saveSession({
playerName,
fullPlayerName: fullName,
currentView: 'lobby'
});
setView('lobby');
actions.refreshRooms();
}
};
const handleLogout = () => {
clearSession();
setView('login');
setPlayerName('');
setFullPlayerName('');
// Si está en una partida, salir
if (gameState?.roomId) {
actions.leaveGame();
}
};
const handleCreateGame = (e: React.FormEvent) => {
e.preventDefault();
actions.createGame(fullPlayerName, createConfig.maxPlayers, createConfig.password);
@@ -149,8 +215,9 @@ export default function Home() {
return (
<GameBoard
gameState={gameState}
currentPlayerId={socket.id}
currentPlayerId={socket.id || ''}
actions={actions}
fullPlayerName={fullPlayerName}
/>
);
}
@@ -178,9 +245,26 @@ export default function Home() {
</h1>
</div>
{view === 'lobby' && (
<div className="flex items-center gap-4 bg-black/50 px-4 py-2 rounded border border-white/10">
<span className="text-sm text-gray-400">AGENTE:</span>
<span className="font-bold text-yellow-500">{fullPlayerName}</span>
<div className="flex items-center gap-3 bg-black/50 px-4 py-2 rounded border border-white/10">
<div className="flex flex-col text-right">
<span className="text-[10px] text-gray-400 uppercase tracking-wider">AGENTE</span>
<span className="font-bold text-yellow-500 text-sm">{fullPlayerName}</span>
</div>
{/* Help Button */}
<motion.button
onClick={() => setShowHelp(true)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="bg-blue-900/80 hover:bg-blue-800 text-white p-2 rounded-full border border-blue-700/50 backdrop-blur-sm shadow-lg transition-all"
title="Ayuda / Reglas"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M12 17.25h.007v.008H12v-.008z" />
</svg>
</motion.button>
<LogoutButton onClick={handleLogout} />
</div>
)}
</div>
@@ -230,7 +314,7 @@ export default function Home() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="w-full max-w-5xl"
className="w-full max-w-5xl h-full flex flex-col max-h-[75vh]"
>
<div className="flex justify-between items-end mb-6 border-b border-white/20 pb-4">
<div>
@@ -245,63 +329,65 @@ export default function Home() {
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{roomsList.length === 0 ? (
<div className="col-span-full py-20 text-center text-gray-500 bg-black/30 rounded border border-white/5 border-dashed">
No hay misiones activas en este momento.
</div>
) : (
roomsList.map((room) => (
<motion.div
key={room.id}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-black/60 border border-white/10 p-5 rounded hover:border-yellow-700/50 transition-colors group relative overflow-hidden"
>
<div className="absolute top-0 right-0 p-2">
{room.isPrivate ? (
<span title="Privada" className="text-red-400">🔒</span>
) : (
<span title="Pública" className="text-green-400/50">🔓</span>
)}
</div>
<h3 className="text-xl font-bold text-yellow-500 mb-1 group-hover:text-yellow-400 transition-colors">
{room.name}
</h3>
<div className="text-sm text-gray-400 mb-4 flex gap-2">
<span className="bg-white/10 px-2 py-0.5 rounded textxs">
HOST:
</span>
<span className="text-white">{room.hostId.substring(0, 6)}...</span>
</div>
<div className="flex justify-between items-center mt-4">
<div className="flex items-end gap-1">
<span className="text-3xl font-bold text-white">{room.currentPlayers}</span>
<span className="text-sm text-gray-500 mb-1">/ {room.maxPlayers}</span>
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{roomsList.length === 0 ? (
<div className="col-span-full py-20 text-center text-gray-500 bg-black/30 rounded border border-white/5 border-dashed">
No hay misiones activas en este momento.
</div>
) : (
roomsList.map((room) => (
<motion.div
key={room.id}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-black/60 border border-white/10 p-5 rounded hover:border-yellow-700/50 transition-colors group relative overflow-hidden"
>
<div className="absolute top-0 right-0 p-2">
{room.isPrivate ? (
<span title="Privada" className="text-red-400">🔒</span>
) : (
<span title="Pública" className="text-green-400/50">🔓</span>
)}
</div>
<button
disabled={room.currentPlayers >= room.maxPlayers || room.status !== 'waiting'}
onClick={() => requestJoinGame(room)}
className="bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded text-xs uppercase font-bold transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{room.status === 'playing' ? 'EN CURSO' : (room.currentPlayers >= room.maxPlayers ? 'LLENA' : 'UNIRSE')}
</button>
</div>
<h3 className="text-xl font-bold text-yellow-500 mb-1 group-hover:text-yellow-400 transition-colors">
{room.name}
</h3>
{/* Barra de progreso visual */}
<div className="absolute bottom-0 left-0 h-1 bg-yellow-900/40 w-full">
<div
className="h-full bg-yellow-600 transition-all duration-500"
style={{ width: `${(room.currentPlayers / room.maxPlayers) * 100}%` }}
/>
</div>
</motion.div>
))
)}
<div className="text-sm text-gray-400 mb-4 flex gap-2">
<span className="bg-white/10 px-2 py-0.5 rounded textxs">
HOST:
</span>
<span className="text-white">{room.hostId.substring(0, 6)}...</span>
</div>
<div className="flex justify-between items-center mt-4">
<div className="flex items-end gap-1">
<span className="text-3xl font-bold text-white">{room.currentPlayers}</span>
<span className="text-sm text-gray-500 mb-1">/ {room.maxPlayers}</span>
</div>
<button
disabled={room.currentPlayers >= room.maxPlayers || room.status !== 'waiting'}
onClick={() => requestJoinGame(room)}
className="bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded text-xs uppercase font-bold transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{room.status === 'playing' ? 'EN CURSO' : (room.currentPlayers >= room.maxPlayers ? 'LLENA' : 'UNIRSE')}
</button>
</div>
{/* Barra de progreso visual */}
<div className="absolute bottom-0 left-0 h-1 bg-yellow-900/40 w-full">
<div
className="h-full bg-yellow-600 transition-all duration-500"
style={{ width: `${(room.currentPlayers / room.maxPlayers) * 100}%` }}
/>
</div>
</motion.div>
))
)}
</div>
</div>
</motion.div>
)}
@@ -383,6 +469,9 @@ export default function Home() {
<div className="absolute bottom-2 right-4 text-[10px] text-gray-600 font-mono">
{isConnected ? <span className="text-green-900"> CONEXIÓN SEGURA</span> : <span className="text-red-900"> BUSCANDO SEÑAL...</span>}
</div>
{/* Modal de Ayuda */}
<HelpModal isOpen={showHelp} onClose={() => setShowHelp(false)} />
</main>
);
}

View File

@@ -0,0 +1,79 @@
import { motion } from 'framer-motion';
import { useState } from 'react';
interface ExitGameButtonProps {
onExit: () => void;
playerName: string;
}
export default function ExitGameButton({ onExit, playerName }: ExitGameButtonProps) {
const [showConfirm, setShowConfirm] = useState(false);
const handleConfirmExit = () => {
setShowConfirm(false);
onExit();
};
return (
<>
<motion.button
onClick={() => setShowConfirm(true)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="fixed top-4 right-0 z-50 bg-gradient-to-l from-green-600 to-green-700 hover:from-green-500 hover:to-green-600 text-white rounded-l-lg px-2 py-3 shadow-lg border-2 border-green-500 border-r-0 transition-all hover:shadow-green-500/50"
title="Volver al lobby"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={3}
stroke="currentColor"
className="w-4 h-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
</motion.button>
{/* Modal de confirmación */}
{showConfirm && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-zinc-900 p-6 rounded-lg border border-red-700/50 w-full max-w-md mx-4 shadow-2xl"
>
<h3 className="text-xl font-bold text-red-400 mb-4 uppercase flex items-center gap-2">
Abandonar Partida
</h3>
<p className="text-gray-300 mb-2">
¿Estás seguro de que quieres abandonar la partida?
</p>
<p className="text-sm text-gray-400 mb-6">
La partida se cerrará para todos los jugadores y se perderá todo el progreso.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowConfirm(false)}
className="flex-1 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded font-bold uppercase text-sm transition-colors"
>
Cancelar
</button>
<button
onClick={handleConfirmExit}
className="flex-1 py-3 bg-red-900 hover:bg-red-800 text-white rounded font-bold uppercase text-sm transition-colors"
>
Salir
</button>
</div>
</motion.div>
</div>
)}
</>
);
}

View File

@@ -5,14 +5,16 @@ import { GameState, GamePhase, Player, GAME_CONFIG, Faction } from '../../../sha
import MissionReveal from './MissionReveal';
import MissionResult from './MissionResult';
import VictoryScreen from './VictoryScreen';
import ExitGameButton from './ExitGameButton';
interface GameBoardProps {
gameState: GameState;
currentPlayerId: string;
actions: any;
fullPlayerName: string;
}
export default function GameBoard({ gameState, currentPlayerId, actions }: GameBoardProps) {
export default function GameBoard({ gameState, currentPlayerId, actions, fullPlayerName }: GameBoardProps) {
const [selectedTeam, setSelectedTeam] = useState<string[]>([]);
// Hooks para FASE REVEAL ROLE
@@ -26,10 +28,10 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
const [expandedMission, setExpandedMission] = useState<number | null>(null);
// Estado para controlar el colapso del panel de jugadores
const [isPlayersCollapsed, setIsPlayersCollapsed] = useState(false);
const [isPlayersCollapsed, setIsPlayersCollapsed] = useState(true);
// Estado para controlar el colapso del historial de misiones
const [isHistoryCollapsed, setIsHistoryCollapsed] = useState(false);
const [isHistoryCollapsed, setIsHistoryCollapsed] = useState(true);
// Timer para avanzar automáticamente en REVEAL_ROLE
@@ -298,6 +300,14 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
return (
<div className="relative w-full h-screen flex flex-col overflow-hidden">
{/* Botón de Salir de la Partida - No mostrar en pantallas de victoria */}
{gameState.phase !== GamePhase.ALLIED_WIN && gameState.phase !== GamePhase.NAZIS_WIN && (
<ExitGameButton
onExit={() => actions.leaveGame()}
playerName={fullPlayerName}
/>
)}
{/* Fondo */}
<div className="absolute inset-0 z-0 opacity-40">
<Image
@@ -887,7 +897,7 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
{/* HISTÓRICO DE MISIONES (Esquina superior derecha) */}
{gameState.missionHistory.length > 0 && (
<motion.div
className="fixed top-4 right-0 z-50"
className="fixed top-[60px] right-0 z-50"
initial={false}
animate={{
x: isHistoryCollapsed ? '0%' : '0%'
@@ -986,7 +996,7 @@ function VotingTimer() {
}, [timeLeft]);
return (
<div className="fixed top-5 left-5 bg-red-600/80 text-white w-16 h-16 rounded-full flex items-center justify-center border-4 border-red-400 animate-pulse text-2xl font-bold font-mono">
<div className="mx-auto my-2 bg-gray-900/90 text-white w-20 h-20 rounded-full flex items-center justify-center border-4 border-gray-500 text-3xl font-bold font-mono shadow-xl relative z-20">
{timeLeft}
</div>
);

View File

@@ -0,0 +1,174 @@
import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image';
interface HelpModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function HelpModal({ isOpen, onClose }: HelpModalProps) {
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="relative w-full max-w-4xl max-h-[90vh] bg-[#1a1a1a] rounded-lg border-2 border-[#8b7355] shadow-2xl overflow-hidden flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* Header estilo Carpeta Confidencial */}
<div className="bg-[#2d2d2d] p-4 border-b border-[#8b7355] flex justify-between items-center bg-[url('/assets/images/ui/paper_texture_dark.png')]">
<div className="flex items-center gap-3">
<div className="bg-red-900 text-white text-xs font-bold px-2 py-1 uppercase tracking-widest border border-red-700">
Top Secret
</div>
<h2 className="text-xl md:text-2xl font-bold text-[#d4b483] uppercase tracking-wider font-mono">
Dossier de Misión
</h2>
</div>
<button
onClick={onClose}
className="text-[#8b7355] hover:text-[#d4b483] transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Contenido Scrollable */}
<div className="flex-1 overflow-y-auto p-6 md:p-8 space-y-8 text-gray-300 font-serif leading-relaxed custom-scrollbar">
{/* 1. INTRODUCCIÓN */}
<section>
<h3 className="text-xl font-bold text-yellow-500 mb-3 uppercase tracking-widest border-b border-white/10 pb-2 font-mono">
1. Objetivo del Juego
</h3>
<p className="mb-4">
<strong className="text-blue-400">La Resistencia</strong> debe completar con éxito <strong>3 misiones</strong>.
<br />
<strong className="text-red-400">Los Espías</strong> deben sabotear <strong>3 misiones</strong> o asesinar al líder de la Resistencia (Marlene) al final del juego.
</p>
</section>
{/* 2. ROLES */}
<section>
<h3 className="text-xl font-bold text-yellow-500 mb-4 uppercase tracking-widest border-b border-white/10 pb-2 font-mono">
2. Identidades Ocultas
</h3>
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-blue-900/10 p-4 rounded border border-blue-500/20">
<h4 className="font-bold text-blue-400 mb-2 flex items-center gap-2">
🔵 LA RESISTENCIA (Aliados)
</h4>
<ul className="space-y-3 text-sm">
<li>
<strong className="text-white">Marlene:</strong> Conoce la identidad de los Espías, pero debe permanecer oculta. Si los espías la descubren al final, la Resistencia pierde.
</li>
<li>
<strong className="text-white">Partisanos:</strong> Miembros leales. No tienen información privilegiada.
</li>
</ul>
</div>
<div className="bg-red-900/10 p-4 rounded border border-red-500/20">
<h4 className="font-bold text-red-500 mb-2 flex items-center gap-2">
🔴 EL EJE (Espías)
</h4>
<ul className="space-y-3 text-sm">
<li>
<strong className="text-white">Francotirador (Asesino):</strong> Si la Resistencia gana las 3 misiones, tiene una "bala de plata" para matar a Marlene e invertir la victoria.
</li>
<li>
<strong className="text-white">Colaboracionista:</strong> Espía estándar. Conoce a sus compañeros.
</li>
</ul>
</div>
</div>
</section>
{/* 3. MECÁNICA DE JUEGO */}
<section>
<h3 className="text-xl font-bold text-yellow-500 mb-4 uppercase tracking-widest border-b border-white/10 pb-2 font-mono">
3. Desarrollo de la Partida
</h3>
<div className="space-y-6">
<div>
<h4 className="font-bold text-white mb-1">A. Asignación de Líder</h4>
<p className="text-sm">El liderazgo rota en sentido horario cada ronda. El Líder propone un equipo para la misión.</p>
</div>
<div>
<h4 className="font-bold text-white mb-1">B. Construcción de Equipo</h4>
<p className="text-sm">El Líder selecciona a los jugadores que irán a la misión. El número de jugadores requeridos varía según la ronda y el total de jugadores.</p>
</div>
<div>
<h4 className="font-bold text-white mb-1">C. Votación de Equipo</h4>
<p className="text-sm pb-2">Todos los jugadores discuten y votan públicamente si aprueban o rechazan al líder propuesto.</p>
<ul className="list-disc list-inside text-sm text-gray-400 pl-4">
<li>Si la mayoría aprueba, la misión procede.</li>
<li>Si se rechaza (o hay empate), el liderazgo pasa al siguiente jugador.</li>
<li><strong className="text-red-400">ATENCIÓN:</strong> Si se rechazan 5 equipos consecutivos en una misma ronda, los Espías ganan automáticamente la partida.</li>
</ul>
</div>
<div>
<h4 className="font-bold text-white mb-1">D. Ejecución de la Misión</h4>
<p className="text-sm pb-2">Los miembros del equipo votan en secreto usándo cartas de "ÉXITO" o "SABOTAJE".</p>
<ul className="list-disc list-inside text-sm text-gray-400 pl-4">
<li><strong className="text-blue-400">La Resistencia</strong> SOLO puede votar ÉXITO.</li>
<li><strong className="text-red-400">Los Espías</strong> pueden elegir entre ÉXITO (para disimular) o SABOTAJE.</li>
<li>Las cartas se barajan y se revelan. <strong>Un solo voto de SABOTAJE hace fracasar la misión</strong>.</li>
<li><em className="text-yellow-500 text-xs">Excepción: En partidas de 7+ jugadores, la 4ª misión requiere 2 sabotajes para fallar.</em></li>
</ul>
</div>
</div>
</section>
{/* 4. FINAL */}
<section>
<h3 className="text-xl font-bold text-yellow-500 mb-3 uppercase tracking-widest border-b border-white/10 pb-2 font-mono">
4. Final de Partida
</h3>
<p className="mb-2">
Si hay <strong>3 misiones fallidas</strong>: <span className="text-red-500 font-bold">VICTORIA DEL EJE</span>.
</p>
<p className="mb-4">
Si hay <strong>3 misiones exitosas</strong>: Comienza la <span className="text-red-500 font-bold">FASE DE ASESINATO</span>.
</p>
<div className="bg-zinc-800 p-4 rounded border-l-4 border-red-600">
<h4 className="font-bold text-white uppercase mb-1">El Disparo Final</h4>
<p className="text-sm text-gray-300">
El Francotirador revela su identidad y tiene una oportunidad para adivinar quién es <strong>Marlene</strong>.
Si acierta, asesina a Marlene y los Espías roban la victoria. Si falla, la Resistencia gana definitivamente.
</p>
</div>
</section>
</div>
{/* Footer / Botón Cerrar */}
<div className="p-4 bg-[#2d2d2d] border-t border-[#8b7355] flex justify-end">
<button
onClick={onClose}
className="bg-[#8b7355] hover:bg-[#a68b66] text-black font-bold py-2 px-6 rounded shadow-lg uppercase tracking-wider transition-all"
>
Comprendido
</button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,32 @@
import { motion } from 'framer-motion';
interface LogoutButtonProps {
onClick: () => void;
}
export default function LogoutButton({ onClick }: LogoutButtonProps) {
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="bg-red-900/80 hover:bg-red-800 text-white p-2 rounded-full border border-red-700/50 backdrop-blur-sm shadow-lg transition-all"
title="Salir del juego"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2.5}
stroke="currentColor"
className="w-5 h-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.636 5.636a9 9 0 1012.728 0M12 3v9"
/>
</svg>
</motion.button>
);
}

View File

@@ -1,5 +1,5 @@
import { motion } from 'framer-motion';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import Image from 'next/image';
interface MissionRevealProps {
@@ -8,6 +8,16 @@ interface MissionRevealProps {
}
export default function MissionReveal({ votes, onFinished }: MissionRevealProps) {
// Barajar votos de forma aleatoria en cada cliente (orden diferente para cada jugador)
const shuffledVotes = useMemo(() => {
const shuffled = [...votes];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}, [votes]);
// Timer de seguridad: 5 segundos y avanza
useEffect(() => {
const timer = setTimeout(() => {
@@ -28,7 +38,7 @@ export default function MissionReveal({ votes, onFinished }: MissionRevealProps)
</h2>
<div className="flex gap-4 justify-center mb-12 flex-wrap max-w-[90vw]">
{votes.map((vote, idx) => (
{shuffledVotes.map((vote, idx) => (
<motion.div
key={idx}
className="w-32 h-48 rounded-xl flex items-center justify-center shadow-2xl relative overflow-hidden"
@@ -55,7 +65,7 @@ export default function MissionReveal({ votes, onFinished }: MissionRevealProps)
className="text-white text-xl font-mono mt-8 text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: votes.length * 0.3 + 0.5 }}
transition={{ delay: shuffledVotes.length * 0.3 + 0.5 }}
>
<span className="animate-pulse">Analizando resultado estratégico...</span>
</motion.div>

View File

@@ -0,0 +1,53 @@
import { useState, useEffect } from 'react';
interface SessionData {
playerName: string;
fullPlayerName: string;
currentView: 'login' | 'lobby' | 'game';
roomId?: string;
}
export function useSessionStorage() {
const [session, setSession] = useState<SessionData | null>(null);
// Cargar sesión al iniciar
useEffect(() => {
const savedSession = localStorage.getItem('resistencia_session');
if (savedSession) {
try {
const parsed = JSON.parse(savedSession);
setSession(parsed);
} catch (e) {
console.error('Error parsing session:', e);
localStorage.removeItem('resistencia_session');
}
}
}, []);
// Guardar sesión
const saveSession = (data: SessionData) => {
localStorage.setItem('resistencia_session', JSON.stringify(data));
setSession(data);
};
// Actualizar sesión parcialmente
const updateSession = (partial: Partial<SessionData>) => {
if (session) {
const updated = { ...session, ...partial };
saveSession(updated);
}
};
// Limpiar sesión
const clearSession = () => {
localStorage.removeItem('resistencia_session');
setSession(null);
};
return {
session,
saveSession,
updateSession,
clearSession
};
}

View File

@@ -50,6 +50,12 @@ export const useSocket = () => {
setGameState(null); // Resetear estado para volver al lobby
});
// Manejar cuando un jugador abandona la partida
socketInstance.on('player_left_game', ({ playerName }: { playerName: string }) => {
console.log(`${playerName} ha abandonado la partida`);
// El servidor ya habrá cerrado la partida, solo mostramos mensaje
});
setSocket(socketInstance);
return () => {
@@ -90,6 +96,14 @@ export const useSocket = () => {
socket?.emit('assassin_kill', { roomId: gameState?.roomId, targetId });
};
const leaveGame = () => {
socket?.emit('leave_game', { roomId: gameState?.roomId });
};
const reconnectSession = (sessionData: { playerName: string; roomId?: string }) => {
socket?.emit('reconnect_session', sessionData);
};
return {
socket,
isConnected,
@@ -105,6 +119,8 @@ export const useSocket = () => {
voteMission,
voteLeader: (approve: boolean) => socket?.emit('vote_leader', { roomId: gameState?.roomId, approve }),
assassinKill,
leaveGame,
reconnectSession,
finishIntro: () => socket?.emit('finish_intro', { roomId: gameState?.roomId }),
finishReveal: () => socket?.emit('finish_reveal', { roomId: gameState?.roomId }),
finishRollCall: () => socket?.emit('finish_roll_call', { roomId: gameState?.roomId }),

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,8 @@ services:
build:
context: .
dockerfile: client/Dockerfile
args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:4000}
ports:
- "3000:3000"
volumes:
@@ -12,7 +14,7 @@ services:
- ./shared:/app/shared
- /app/client/node_modules
environment:
- NEXT_PUBLIC_API_URL=http://localhost:4000
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:4000}
depends_on:
- server
networks:
@@ -32,8 +34,8 @@ services:
- /app/server/node_modules
environment:
- PORT=4000
- DATABASE_URL=postgresql://postgres:password@db:5432/resistencia
- CORS_ORIGIN=http://localhost:3000
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:password@db:5432/resistencia}
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3000}
depends_on:
- db
networks:

View File

@@ -5,6 +5,9 @@ services:
build:
context: .
dockerfile: client/Dockerfile
args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://api.franciaocupada.martivich.es}
- NEXT_PUBLIC_ADMIN_PASSWORD=${NEXT_PUBLIC_ADMIN_PASSWORD:-admin123}
ports:
- "3000:3000"
volumes:
@@ -12,7 +15,8 @@ services:
- ./shared:/app/shared
- /app/client/node_modules
environment:
- NEXT_PUBLIC_API_URL=https://api.franciaocupada.martivich.es
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://api.franciaocupada.martivich.es}
- NEXT_PUBLIC_ADMIN_PASSWORD=${NEXT_PUBLIC_ADMIN_PASSWORD:-admin123}
depends_on:
- server
networks:
@@ -32,8 +36,8 @@ services:
- /app/server/node_modules
environment:
- PORT=4000
- DATABASE_URL=postgresql://postgres:password@db:5432/resistencia
- CORS_ORIGIN=https://franciaocupada.martivich.es
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:password@db:5432/resistencia}
- CORS_ORIGIN=${CORS_ORIGIN:-https://franciaocupada.martivich.es}
depends_on:
- db
networks:

1960
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "resistencia-server",
"version": "1.0.0",
"version": "1.3.0",
"description": "Backend para el juego La Resistencia",
"main": "src/index.ts",
"scripts": {
@@ -12,9 +12,11 @@
"author": "",
"license": "ISC",
"dependencies": {
"@types/pg": "^8.16.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"pg": "^8.16.3",
"socket.io": "^4.7.2"
},
"devDependencies": {

106
server/src/db.ts Normal file
View File

@@ -0,0 +1,106 @@
import { Pool } from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:password@db:5432/resistencia',
});
// Inicializar base de datos
export const initDb = async () => {
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS game_logs (
id SERIAL PRIMARY KEY,
room_id UUID NOT NULL,
room_name TEXT NOT NULL,
host_name TEXT NOT NULL,
players TEXT NOT NULL, -- Lista de nombres separada por comas
max_players INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP,
winner TEXT, -- 'resistance' o 'spies'
status TEXT DEFAULT 'active' -- 'active', 'finished', 'aborted'
);
`);
// Migration: Add new columns if they don't exist
await client.query(`
ALTER TABLE game_logs
ADD COLUMN IF NOT EXISTS rounds_played INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS round_results TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS matches_played INTEGER DEFAULT 1,
ADD COLUMN IF NOT EXISTS match_results TEXT DEFAULT '';
`);
console.log('[DB] Base de datos inicializada correctamente');
} catch (err) {
console.error('[DB] Error al inicializar base de datos:', err);
} finally {
client.release();
}
};
// Registrar inicio de partida
export const logGameStart = async (roomId: string, roomName: string, hostName: string, maxPlayers: number) => {
try {
await pool.query(
'INSERT INTO game_logs (room_id, room_name, host_name, players, max_players, status) VALUES ($1, $2, $3, $4, $5, $6)',
[roomId, roomName, hostName, hostName, maxPlayers, 'active']
);
} catch (err) {
console.error('[DB] Error al registrar inicio de partida:', err);
}
};
// Actualizar lista de jugadores en tiempo real (opcional, pero útil para el histórico final)
export const updateGamePlayers = async (roomId: string, players: string[]) => {
try {
await pool.query(
'UPDATE game_logs SET players = $1 WHERE room_id = $2 AND status = $3',
[players.join(', '), roomId, 'active']
);
} catch (err) {
console.error('[DB] Error al actualizar jugadores en log:', err);
}
};
// Registrar fin de partida
export const logGameEnd = async (
roomId: string,
winner: string | null = null,
aborted: boolean = false,
roundsPlayed: number = 0,
roundResults: boolean[] = [],
matchesPlayed: number = 1,
matchResults: string[] = []
) => {
try {
// Convert boolean[] to string "true,false,true"
const roundResultsStr = roundResults.map(r => r === true ? 'true' : r === false ? 'false' : '').filter(r => r).join(',');
// matchResults is already string[] like ["aliados", "alemanes"]
const matchResultsStr = matchResults.join(',');
await pool.query(
'UPDATE game_logs SET finished_at = CURRENT_TIMESTAMP, winner = $1, status = $2, rounds_played = $3, round_results = $4, matches_played = $5, match_results = $6 WHERE room_id = $7 AND status = $8',
[winner, aborted ? 'aborted' : 'finished', roundsPlayed, roundResultsStr, matchesPlayed, matchResultsStr, roomId, 'active']
);
} catch (err) {
console.error('[DB] Error al registrar fin de partida:', err);
}
};
// Obtener historial
export const getGameHistory = async () => {
try {
const res = await pool.query('SELECT * FROM game_logs ORDER BY created_at DESC LIMIT 50');
return res.rows;
} catch (err) {
console.error('[DB] Error al obtener historial:', err);
return [];
}
};
export default pool;

View File

@@ -6,21 +6,38 @@ import dotenv from 'dotenv';
import crypto from 'crypto';
import { Game } from './models/Game';
import { GamePhase } from '../../shared/types';
import { initDb, logGameStart, logGameEnd, updateGamePlayers, getGameHistory } from './db';
dotenv.config();
// Inicializar DB
initDb();
const app = express();
const port = process.env.PORT || 4000;
const allowedOrigins = [
process.env.CORS_ORIGIN || "http://localhost:3000",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://192.168.1.131:3000"
];
app.use(cors({
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ["GET", "POST"]
}));
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
origin: allowedOrigins,
methods: ["GET", "POST"]
}
});
@@ -66,6 +83,31 @@ const generateRoomName = () => {
return `${MISSION_NAMES[idx]} #${suffix}`;
};
const getAdminData = async () => {
const activeGamesData = Object.values(games).map(g => ({
id: g.state.roomId,
name: g.roomName,
status: g.state.phase,
currentPlayers: g.state.players.length,
maxPlayers: g.maxPlayers,
currentRound: g.state.currentRound,
matchNumber: g.state.matchNumber,
players: g.state.players.map(p => ({ id: p.id, name: p.name }))
}));
const history = await getGameHistory();
return {
activeGames: activeGamesData,
history: history
};
};
const broadcastAdminUpdate = async () => {
const data = await getAdminData();
io.to('admin-room').emit('admin_data', data);
};
io.on('connection', (socket) => {
console.log('Cliente conectado:', socket.id);
@@ -90,6 +132,10 @@ io.on('connection', (socket) => {
// Actualizar lista a todos
io.emit('rooms_list', getRoomsList());
// LOG EN DB
logGameStart(roomId, roomName, hostName, maxPlayers);
broadcastAdminUpdate();
});
// B. UNIRSE A SALA
@@ -126,6 +172,10 @@ io.on('connection', (socket) => {
// Actualizar lista de salas (cambió contador de jugadores)
io.emit('rooms_list', getRoomsList());
// ACTUALIZAR LOG EN DB
updateGamePlayers(roomId, game.state.players.map(p => p.name));
broadcastAdminUpdate();
});
// C. REFRESCAR LISTA
@@ -298,14 +348,165 @@ io.on('connection', (socket) => {
// Desconectar a todos los jugadores de la sala
io.in(roomId).socketsLeave(roomId);
// LOG EN DB - Incluir resultado de la partida actual en matchResults
const finalMatchResults = [...game.state.matchResults];
if (game.state.winner) finalMatchResults.push(game.state.winner);
logGameEnd(roomId, game.state.winner, false, game.state.currentRound, game.state.questResults, game.state.matchNumber, finalMatchResults);
broadcastAdminUpdate();
}
});
// 9. DESCONEXIÓN
// 8. ABANDONAR PARTIDA (cualquier jugador)
socket.on('leave_game', ({ roomId }) => {
const game = games[roomId];
if (game) {
// Encontrar el jugador que se va
const leavingPlayer = game.state.players.find(p => p.id === socket.id);
const playerName = leavingPlayer?.name || 'Un jugador';
// Notificar a todos los jugadores
io.to(roomId).emit('player_left_game', { playerName });
io.to(roomId).emit('game_finalized');
// Eliminar la partida de la base de datos
delete games[roomId];
// Limpiar timer si existe
if (voteTimers[roomId]) {
clearTimeout(voteTimers[roomId]);
delete voteTimers[roomId];
}
// Actualizar lista de salas
io.emit('rooms_list', getRoomsList());
// Desconectar a todos de la sala
io.in(roomId).socketsLeave(roomId);
// LOG EN DB COMO ABORTADA
const finalMatchResults = [...game.state.matchResults];
if (game.state.winner) finalMatchResults.push(game.state.winner);
logGameEnd(roomId, null, true, game.state.currentRound, game.state.questResults, game.state.matchNumber, finalMatchResults);
broadcastAdminUpdate();
console.log(`[LEAVE_GAME] ${playerName} abandonó la partida ${roomId}. Partida eliminada.`);
}
});
// 9. RECONECTAR SESIÓN
socket.on('reconnect_session', ({ playerName, roomId }) => {
console.log(`[RECONNECT_SESSION] Intento de reconexión: ${playerName} a sala ${roomId}`);
if (roomId) {
const game = games[roomId];
if (game) {
// Buscar si el jugador existe en la partida
const existingPlayer = game.state.players.find(p => p.name === playerName);
if (existingPlayer) {
// Actualizar el socket ID del jugador y referencias
game.updatePlayerSocket(existingPlayer.id, socket.id);
// Unir al socket a la sala
socket.join(roomId);
// Enviar estado actualizado
socket.emit('game_joined', { roomId, state: game.state });
io.to(roomId).emit('game_state', game.state);
console.log(`[RECONNECT_SESSION] ${playerName} reconectado exitosamente a ${roomId}`);
} else {
console.log(`[RECONNECT_SESSION] Jugador ${playerName} no encontrado en partida ${roomId}`);
socket.emit('error', 'No se pudo reconectar a la partida');
}
} else {
console.log(`[RECONNECT_SESSION] Partida ${roomId} no existe`);
socket.emit('error', 'La partida ya no existe');
}
}
});
// 10. FINALIZAR Y EXPULSAR JUGADORES (solo host)
socket.on('finalize_game', ({ roomId }) => {
const game = games[roomId];
if (game && game.hostId === socket.id) {
// Notificar a todos los jugadores que la partida ha sido finalizada
io.to(roomId).emit('game_finalized');
// Eliminar la partida inmediatamente del registro
delete games[roomId];
// Actualizar lista de salas para todos los clientes
io.emit('rooms_list', getRoomsList());
// Desconectar a todos los jugadores de la sala
io.in(roomId).socketsLeave(roomId);
}
});
// 11. DESCONEXIÓN
socket.on('disconnect', () => {
// Buscar en qué partida estaba y sacarlo (opcional, por ahora solo notificamos)
console.log('Desconectado:', socket.id);
// TODO: Eliminar de la partida si está en LOBBY para liberar hueco
});
// --- ADMIN COMMANDS ---
socket.on('admin_get_data', async () => {
console.log('[ADMIN] Agente administrativo conectado');
socket.join('admin-room');
const data = await getAdminData();
socket.emit('admin_data', data);
});
socket.on('admin_close_game', async ({ roomId }) => {
const game = games[roomId];
if (game) {
io.to(roomId).emit('game_finalized');
delete games[roomId];
if (voteTimers[roomId]) {
clearTimeout(voteTimers[roomId]);
delete voteTimers[roomId];
}
io.emit('rooms_list', getRoomsList());
io.in(roomId).socketsLeave(roomId);
// Log como abortada por admin
const finalMatchResults = [...game.state.matchResults];
if (game.state.winner) finalMatchResults.push(game.state.winner);
await logGameEnd(roomId, null, true, game.state.currentRound, game.state.questResults, game.state.matchNumber, finalMatchResults);
socket.emit('admin_action_success');
broadcastAdminUpdate();
}
});
socket.on('admin_kick_player', ({ roomId, targetSocketId }) => {
const game = games[roomId];
if (game) {
const playerIndex = game.state.players.findIndex(p => p.id === targetSocketId);
if (playerIndex !== -1) {
const playerName = game.state.players[playerIndex].name;
game.state.players.splice(playerIndex, 1);
// Notificar al jugador expulsado
io.to(targetSocketId).emit('game_finalized');
const targetSocket = io.sockets.sockets.get(targetSocketId);
targetSocket?.leave(roomId);
// Notificar al resto
io.to(roomId).emit('player_left_game', { playerName: `${playerName} (Expulsado)` });
io.to(roomId).emit('game_state', game.state);
io.emit('rooms_list', getRoomsList());
updateGamePlayers(roomId, game.state.players.map(p => p.name));
socket.emit('admin_action_success');
broadcastAdminUpdate();
}
}
});
});

View File

@@ -43,7 +43,9 @@ export class Game {
missionHistory: [],
revealedVotes: [],
history: [],
hostId: hostId
hostId: hostId,
matchNumber: 1,
matchResults: []
};
}
@@ -369,7 +371,13 @@ export class Game {
restartGame() {
this.log('=== REINICIANDO PARTIDA ===');
// Resetear variables de juego
// Guardar resultado de la partida actual antes de resetear
if (this.state.winner) {
this.state.matchResults.push(this.state.winner);
}
// Incrementar contador de partidas y resetear variables de juego
this.state.matchNumber++;
this.state.currentRound = 1;
this.state.failedVotesCount = 0;
this.state.questResults = [null, null, null, null, null];
@@ -407,4 +415,44 @@ export class Game {
// Mantener solo los últimos 50 mensajes
if (this.state.history.length > 50) this.state.history.shift();
}
updatePlayerSocket(oldId: string, newId: string) {
const player = this.state.players.find(p => p.id === oldId);
if (!player) return;
// Actualizar ID del jugador
player.id = newId;
// Actualizar referencias en el estado
// 1. Host
if (this.hostId === oldId) {
this.hostId = newId;
this.state.hostId = newId;
}
// 2. Líder actual
if (this.state.currentLeaderId === oldId) {
this.state.currentLeaderId = newId;
}
// 3. Votos de Líder (leaderVotes)
if (this.state.leaderVotes && this.state.leaderVotes[oldId] !== undefined) {
this.state.leaderVotes[newId] = this.state.leaderVotes[oldId];
delete this.state.leaderVotes[oldId];
}
// 4. Votos de Equipo (teamVotes)
if (this.state.teamVotes && this.state.teamVotes[oldId] !== undefined) {
this.state.teamVotes[newId] = this.state.teamVotes[oldId];
delete this.state.teamVotes[oldId];
}
// 5. Equipo Propuesto (proposedTeam)
if (this.state.proposedTeam && this.state.proposedTeam.includes(oldId)) {
this.state.proposedTeam = this.state.proposedTeam.map(id => id === oldId ? newId : id);
}
this.log(`Jugador ${player.name} reconectado. ID actualizado.`);
}
}

View File

@@ -89,6 +89,8 @@ export interface GameState {
winner?: Faction;
history: string[]; // Log de acciones para mostrar en pantalla
matchNumber: number; // Número de partida en esta operación (se incrementa con cada restart)
matchResults: Faction[]; // Resultados de cada partida (quién ganó)
}
// Configuración de jugadores por partida (según tus reglas)