57 Commits
v1.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
Resistencia Dev
be15983455 Chore: Actualización a versión 1.0.0
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 7s
2025-12-18 21:23:42 +01:00
Resistencia Dev
94d1ffbf56 Fix: Cambio a Rondas.mp3 y corrección lógica votación
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 7s
2025-12-18 20:31:30 +01:00
Resistencia Dev
c8bad3ea73 Actualización de assets y limpieza de archivos temporales
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-16 17:27:33 +01:00
Resistencia Dev
4e601cdd6f Millores UI: text INTRO, velocitat drag REVEAL_ROLE, colors botons, cartes VOTING_TEAM redissenyades
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-15 14:10:08 +01:00
Resistencia Dev
385f87cce0 Afegit botó per ocultar/mostrar l'historial de missions amb animació de lliscament
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 7s
2025-12-15 09:57:58 +01:00
Resistencia Dev
bb08fa9437 Millores UI: text MISSION_RESULT més gran, text centrat a MISSION_REVEAL, text ASSASSIN_PHASE centrat verticalment
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-15 09:30:26 +01:00
Resistencia Dev
c2f1bbf4d4 Text adaptatiu per MISSION_RESULT: s'ajusta a qualsevol pantalla en una sola línia
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-15 09:18:42 +01:00
Resistencia Dev
e1c08f775e Ajustats textos i mides de cartes: botó 'Ocultar/Mostrar', cartes MISSION mateix tamany que MISSION_REVEAL
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 7s
2025-12-15 09:08:37 +01:00
Resistencia Dev
1422e0fb1d Renombrat deployment.yml a _deployment.yml i afegit docker-compose_prod.yml
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
2025-12-14 22:13:22 +01:00
Resistencia Dev
fc6bc1ab54 Afegit botó per ocultar/mostrar els jugadors
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 7s
2025-12-14 22:06:47 +01:00
Resistencia Dev
404d35f732 fix: Eliminar copia de archivos con rsync
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 5s
- Removido el paso de copia con rsync/cp que causaba error
- El script deploy.sh ya hace git pull para actualizar el código
- Simplifica el workflow y evita dependencias innecesarias
- El runner solo ejecuta el script, no necesita copiar archivos
2025-12-13 15:58:15 +01:00
Resistencia Dev
fd23cae2ff refactor: Simplificar CI/CD usando script de deployment en el host
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 6s
- Creado deploy.sh que se ejecuta directamente en el host
- Simplificado workflow para ejecutar el script en lugar de comandos inline
- El script maneja todo: git pull, docker build, docker deploy
- Evita problemas de 'docker: command not found' en el runner
- El runner solo necesita Node.js para checkout, luego ejecuta el script del host
2025-12-13 15:50:48 +01:00
Resistencia Dev
134460a972 fix: Especificar shell bash explícitamente en todos los pasos
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 5s
- Agregado 'shell: bash' a todos los pasos con bloques 'run:'
- Resuelve el error 'docker: command not found' en scripts 2.sh, 3.sh, 4.sh
- El runner necesita saber explícitamente qué shell usar para ejecutar los comandos
2025-12-13 15:41:37 +01:00
Resistencia Dev
7c9ff5308f feat: Instalación manual de Node.js en el runner
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 7s
- Agregado paso para instalar Node.js usando comandos del sistema
- Soporta Debian/Ubuntu, RedHat/CentOS, Alpine Linux
- Fallback a nvm para otros sistemas
- Necesario porque el runner no tiene Node.js preinstalado
- Las acciones de GitHub requieren Node.js para ejecutarse
2025-12-13 15:38:03 +01:00
Resistencia Dev
0211dfb68b fix: Reordenar pasos del workflow - Node.js antes de checkout
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 2s
- Movido setup-node antes del checkout
- actions/checkout@v4 requiere Node.js en el PATH para funcionar
- Esto debería resolver el error 'Cannot find: node in PATH'
2025-12-13 15:30:35 +01:00
Resistencia Dev
609033b835 fix: Corregir checkout en workflow de Gitea Actions
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 2s
- Eliminados parámetros innecesarios que causaban error de interpolación
- El checkout de Gitea Actions funciona automáticamente sin server-url, repository y token explícitos
2025-12-13 15:27:15 +01:00
Resistencia Dev
273a228a1c feat: Configuración CI/CD con Gitea Actions
Some checks failed
CI/CD - Francia Ocupada (La Resistencia) / build-and-deploy (push) Failing after 40s
- Agregado workflow de deployment automático (.gitea/workflows/deployment.yml)
- Workflow configurado para runner 'production-ready'
- Build y deploy automático con docker-compose_prod.yml
- Verificación de estado post-despliegue
- Documentación completa en CI-CD-README.md
2025-12-13 14:26:38 +01:00
Resistencia Dev
84642bc2a9 docs: Añadir v0.2.0-complete-ui al CHANGELOG
- Documentar todas las nuevas funcionalidades implementadas
- Listar mejoras de UI de la sesión 2025-12-13
- Incluir assets añadidos y correcciones realizadas
- Marcar el juego como completamente funcional
2025-12-13 01:29:38 +01:00
Resistencia Dev
ecb15c27f4 docs: Actualizar README con estado actual del proyecto
- Marcar todas las fases como implementadas (funcional hasta victoria)
- Añadir sección de mejoras de UI recientes (2025-12-13)
- Actualizar problemas conocidos y próximos pasos
- Reflejar que el juego está completamente funcional, solo falta ajuste fino de tokens
2025-12-13 01:26:48 +01:00
Resistencia Dev
13d56c2431 feat: Mejoras UI - Timer, mapa resultado misión y tokens
- Mover timer de votación a esquina superior izquierda (fixed, 20px margen)
- Eliminar contador de votos rechazados en resultado de misión
- Ajustar posiciones de tokens de victoria/fracaso en el mapa
- Mantener mapa visible durante toda la fase MISSION_RESULT (eliminar timeout de 7s)
- Cambiar título intro de 'Guerra Total' a 'Traidores en París'
- Ajustar tamaño de cartas aceptar/rechazar líder a cuadradas (w-32 h-32)

TODO: Afinar posiciones de tokens 3, 4 y 5 en el mapa
2025-12-13 01:18:52 +01:00
Resistencia Dev
c67f97845a feat(ui): Enhance responsive design and game flow
- Intro: Change title to 'Sombras en París'
- Roll Call: Make screen fully responsive with fixed header
- Team Building: Clean up leader UI and make player tokens responsive
- Mission History: Fix expand/collapse interaction (z-index issue)
2025-12-12 19:14:14 +01:00
Resistencia Dev
1a7b667c77 feat: Add 2025-12-10 project log detailing environment, Nginx, and Git/Gitea setup, and update gitignore to exclude GiteaToken.txt. 2025-12-12 18:35:47 +01:00
80 changed files with 11780 additions and 1719 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

View File

@@ -0,0 +1,85 @@
name: CI/CD - Francia Ocupada (La Resistencia)
run-name: Build & Deploy por ${{ gitea.actor }}
on:
push:
branches:
- main
- master
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-deploy:
runs-on: [production-ready]
steps:
# PASO 1: Instalar Node.js (requerido por las acciones de GitHub)
- name: 📦 Instalar Node.js
shell: bash
run: |
echo "Verificando si Node.js está instalado..."
if ! command -v node &> /dev/null; then
echo "Node.js no encontrado, instalando..."
if [ -f /etc/debian_version ]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
elif [ -f /etc/alpine-release ]; then
apk add --no-cache nodejs npm
else
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm install 20
fi
else
echo "Node.js ya está instalado: $(node --version)"
fi
node --version
npm --version
# PASO 2: Checkout del Código
- name: 🚀 Checkout del Código
uses: actions/checkout@v4
with:
fetch-depth: 0
# PASO 3: Ejecutar script de deployment en el host
- name: 🎯 Ejecutar Deployment
shell: bash
run: |
echo "🚀 Ejecutando script de deployment en el host..."
PROJECT_DIR="/home/marti/Documentos/Gitea/resistencia"
echo "📂 Directorio del proyecto: $PROJECT_DIR"
# Ir al directorio del proyecto y ejecutar deployment
cd "$PROJECT_DIR"
# Asegurarse de que el script es ejecutable
chmod +x deploy.sh
# Ejecutar el script de deployment
# El script hará git pull para obtener los últimos cambios
./deploy.sh
# PASO 4: Verificación Final
- name: ✅ Verificación Final
if: always()
shell: bash
run: |
PROJECT_DIR="/home/marti/Documentos/Gitea/resistencia"
cd "$PROJECT_DIR"
echo "📊 Estado final de los contenedores:"
docker compose -f docker-compose_prod.yml ps || echo "No se pudo verificar el estado"
echo ""
echo "🌐 URLs de la aplicación:"
echo " - Frontend: https://franciaocupada.martivich.es"
echo " - API: https://api.franciaocupada.martivich.es"

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ dist/
.env
.DS_Store
postgres_data/
# Credenciales
GiteaToken.txt

View File

@@ -1,171 +0,0 @@
RESUMEN DE CAMBIOS - SESIÓN 2025-12-08
========================================
## 1. CORRECCIÓN DE PANTALLAS DE VICTORIA (ALLIED_WIN y NAZIS_WIN)
### Problema inicial:
- Las pantallas de victoria mostraban el tablero de juego encima de la imagen de fondo
- Había imágenes duplicadas y rutas incorrectas (.jpg vs .png)
### Solución implementada:
#### GameBoard.tsx:
- **Fondo dinámico según fase**: El fondo del componente GameBoard ahora cambia según la fase:
* ALLIED_WIN → muestra `/assets/images/tokens/mission_success.png`
* NAZIS_WIN → muestra `/assets/images/tokens/mission_fail.png`
* Otras fases → muestra `/assets/images/ui/bg_game.png`
- **Área del tablero oculta en victorias**: El div del tablero (con las cartas de misión, tablero táctico, etc.)
se oculta completamente cuando `gameState.phase === ALLIED_WIN || gameState.phase === NAZIS_WIN`
#### VictoryScreen.tsx:
- **Eliminada imagen de fondo redundante**: Se eliminó el div con la imagen de fondo que intentaba cargar
`mission_fail.jpg` y `mission_success.jpg`, ya que el GameBoard ahora maneja estos fondos.
### Archivos modificados:
- `client/src/components/GameBoard.tsx` (líneas 293-307, 309-442)
- `client/src/components/VictoryScreen.tsx` (líneas 39-50 eliminadas)
### Commit:
- Hash: 6e65152
- Mensaje: "feat: Fix victory screens background images"
---
## 2. MEJORA DE CARTAS DE MISIÓN (Fase MISSION)
### Problema inicial:
- Las cartas solo se opacaban cuando se seleccionaba la otra
- Si solo había una carta (jugadores aliados), no había feedback visual de que se había seleccionado
### Solución implementada:
#### Cambio de lógica de opacidad:
**ANTES:**
- Sin voto: todas las cartas al 100% de opacidad
- Con voto: la carta NO seleccionada se opaca al 50%
**DESPUÉS:**
- Sin voto: todas las cartas al 50% de opacidad (opacadas por defecto)
- Con voto: solo la carta seleccionada se pone al 100%, las demás permanecen al 50%
#### Implementación:
```tsx
// Carta de Éxito
className={`group transition-opacity ${missionVote === true ? 'opacity-100' : 'opacity-50'}`}
// Carta de Sabotaje (solo alemanes)
className={`group transition-opacity ${missionVote === false ? 'opacity-100' : 'opacity-50'}`}
```
### Archivos modificados:
- `client/src/components/GameBoard.tsx` (líneas 628-678)
### Beneficio:
- Ahora es fácil ver qué carta has seleccionado, incluso cuando solo tienes una opción disponible
---
## 3. INTENTO DE MEJORA DEL HISTORIAL DE MISIONES (NO FUNCIONAL)
### Objetivo:
- Mostrar los participantes de cada misión al hacer clic en el número del historial
### Implementación intentada:
#### Estado añadido:
```tsx
const [expandedMission, setExpandedMission] = useState<number | null>(null);
```
#### Lógica implementada:
- Click en número de misión → expande mostrando nombres de participantes
- Click de nuevo → colapsa la lista
- Solo una misión puede estar expandida a la vez
- Indicador visual: anillo amarillo alrededor del número cuando está expandido
#### Código añadido en GameBoard.tsx (líneas 856-899):
```tsx
{gameState.missionHistory.map((mission, idx) => {
const isExpanded = expandedMission === idx;
return (
<div key={idx} className="relative">
<div
className={`... ${isExpanded ? 'ring-2 ring-yellow-400' : ''}`}
onClick={(e) => {
e.stopPropagation();
console.log('Click en misión', idx, 'Estado actual:', expandedMission);
setExpandedMission(isExpanded ? null : idx);
}}
>
{mission.round}
</div>
{isExpanded && (
<div className="absolute top-10 right-0 bg-black/95 p-2 rounded border border-white/30 min-w-max z-[100]">
{mission.team.map((playerId) => {
const player = gameState.players.find(p => p.id === playerId);
return (
<div key={playerId} className="text-xs text-white whitespace-nowrap">
{player?.name || playerId}
</div>
);
})}
</div>
)}
</div>
);
})}
```
### Archivos modificados:
- `client/src/components/GameBoard.tsx` (líneas 26, 856-899)
### Estado:
⚠️ **NO FUNCIONAL** - El click no dispara la expansión de la lista de participantes.
Posibles causas a investigar:
- Conflicto con otros event handlers
- Problema con el z-index o posicionamiento
- Estado no actualizándose correctamente
- Necesidad de reiniciar servicios Docker
---
## RESUMEN DE COMMITS
1. **6e65152** - "feat: Fix victory screens background images"
- Corregidas pantallas de victoria
- Eliminadas imágenes redundantes
- Fondo dinámico según fase
---
## ARCHIVOS PRINCIPALES MODIFICADOS
1. `client/src/components/GameBoard.tsx`
- Fondo dinámico para fases de victoria
- Área del tablero oculta en victorias
- Opacidad de cartas de misión mejorada
- Intento de historial expandible (no funcional)
2. `client/src/components/VictoryScreen.tsx`
- Eliminada imagen de fondo redundante
---
## PENDIENTES / PROBLEMAS CONOCIDOS
1. ❌ **Historial de misiones expandible no funciona**
- El código está implementado pero el click no dispara la acción
- Requiere investigación adicional
2. ⚠️ **Errores de lint**
- Múltiples errores de tipo "JSX element implicitly has type 'any'"
- Son falsos positivos del IDE en entorno Dockerizado
- No afectan la funcionalidad de la aplicación
---
Fecha: 2025-12-08
Hora: 22:59

View File

@@ -1,5 +1,59 @@
# Notas de Versión
## v0.2.0-complete-ui (2025-12-13)
### 🎉 Juego Completamente Funcional
Esta versión marca la **finalización de todas las fases del juego**, incluyendo las pantallas de resultado de misión, histórico y victoria.
### ✅ Nuevas Funcionalidades
#### UI de Fases Finales
-**MISSION_REVEAL**: Animación de cartas revelándose una a una
-**MISSION_RESULT**: Pantalla con mapa táctico mostrando tokens de victoria/fracaso
-**Histórico de Misiones**: Círculos clicables en esquina superior derecha
-**Mapa Táctico**: Tablero `board_map.jpg` con tokens posicionados sobre hexágonos
-**Pantallas de Victoria**: Fondos específicos para victoria Aliados/Nazis
-**ASSASSIN_PHASE**: Fase de francotirador completamente funcional
#### Mejoras de UI (2025-12-13)
- ✅ Timer de votación reposicionado a esquina superior izquierda (fixed, 20px margen)
- ✅ Cartas de votación de líder redimensionadas a 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
- ✅ Título de intro cambiado a "Traidores en París"
### 🎨 Assets Añadidos
- Nuevas imágenes de misiones cuadradas en `/missions/quadrades/`
- Cartas de votación de líder cuadradas (`accept_leader.jpg`, `deny_leader.jpg`)
- Respaldo de imágenes originales en `/missions/original_backup/`
### 🐛 Correcciones
- ✅ Eliminado timeout de 7 segundos que ocultaba el mapa en MISSION_RESULT
- ✅ Ajustadas posiciones de tokens en el mapa (tokens 1 y 2 correctos)
### ⚠️ Pendiente
- Ajuste fino de posiciones de tokens 3, 4 y 5 en el mapa táctico
### 📦 Tecnologías
- **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
### 📝 Commits Principales
- `13d56c2` - feat: Mejoras UI - Timer, mapa resultado misión y tokens
- `ecb15c2` - docs: Actualizar README con estado actual del proyecto
### 🎯 Próximos Pasos
1. Afinar posiciones de tokens 3, 4 y 5 en el mapa
2. Testing exhaustivo de todas las fases
3. Optimizar rendimiento del servidor
4. Ajustes finales según feedback de jugadores
---
## v0.1.0-functional-until-mission (2025-12-05)
### ✅ Funcionalidades Implementadas

167
CI-CD-README.md Normal file
View File

@@ -0,0 +1,167 @@
# 🚀 CI/CD con Gitea Actions - Francia Ocupada
## 📋 Configuración Actual
### Runner Configurado
- **Nombre**: `runner-01-ci-vm`
- **Etiqueta**: `production-ready:host`
- **URL Gitea**: `http://gitea.local:3000`
- **Estado**: ✅ Corriendo sin problemas
### Workflow Creado
- **Ubicación**: `.gitea/workflows/deployment.yml`
- **Trigger**: Push a `main` o `master`, o ejecución manual
- **Acciones**: Build y deploy automático con Docker Compose
## 🔄 Flujo de Despliegue
El workflow ejecuta los siguientes pasos:
1. **🚀 Checkout del Código**: Descarga el código del repositorio
2. **⚙️ Configurar Node.js**: Instala Node.js 20 para las acciones
3. **🛑 Detener Contenedores Anteriores**: Para y elimina los contenedores existentes
4. **🧹 Limpiar Imágenes Antiguas**: Elimina imágenes Docker sin usar
5. **🔨 Construir Imágenes Docker**: Construye las imágenes con `docker-compose_prod.yml`
6. **📦 Desplegar Aplicación**: Levanta los contenedores en modo producción
7. **✅ Verificar Despliegue**: Comprueba que los contenedores están corriendo
8. **📋 Mostrar Logs Recientes**: Muestra logs para debugging
## 🧪 Cómo Hacer Pruebas de Despliegue
### Opción 1: Push a la rama principal (Automático)
```bash
# Hacer algún cambio en el código
echo "# Test CI/CD" >> README.md
# Commit y push
git add .
git commit -m "test: Prueba de CI/CD automático"
git push origin main # o master, según tu rama principal
```
### Opción 2: Ejecución Manual desde Gitea
1. Ve a tu repositorio en Gitea: `http://gitea.local:3000/[tu-usuario]/resistencia`
2. Navega a la pestaña **Actions**
3. Selecciona el workflow **"CI/CD - Francia Ocupada (La Resistencia)"**
4. Haz clic en **"Run workflow"**
5. Selecciona la rama y confirma
### Opción 3: Forzar un push vacío (sin cambios)
```bash
# Esto dispara el workflow sin hacer cambios reales
git commit --allow-empty -m "test: Trigger CI/CD workflow"
git push origin main
```
## 📊 Monitorear el Despliegue
### Desde Gitea Web UI
1. Ve a **Actions** en tu repositorio
2. Verás el workflow ejecutándose en tiempo real
3. Haz clic en el workflow para ver los logs detallados de cada paso
### Desde el Servidor (SSH)
```bash
# Ver estado de los contenedores
docker compose -f docker-compose_prod.yml ps
# Ver logs en tiempo real
docker compose -f docker-compose_prod.yml logs -f
# Ver logs de un servicio específico
docker compose -f docker-compose_prod.yml logs -f client
docker compose -f docker-compose_prod.yml logs -f server
docker compose -f docker-compose_prod.yml logs -f db
# Ver imágenes Docker
docker images | grep resistencia
```
## 🔧 Solución de Problemas
### El workflow falla en el checkout
**Problema**: Error de autenticación con Gitea
**Solución**:
- Verifica que el token de registro del runner sea correcto
- Asegúrate de que el runner tenga acceso al repositorio
### El workflow falla en la construcción
**Problema**: Error al construir las imágenes Docker
**Solución**:
```bash
# En el servidor, construir manualmente para ver el error
cd /ruta/al/proyecto
docker compose -f docker-compose_prod.yml build --no-cache
```
### Los contenedores no inician
**Problema**: Los contenedores se detienen inmediatamente
**Solución**:
```bash
# Ver logs de error
docker compose -f docker-compose_prod.yml logs
# Verificar configuración
docker compose -f docker-compose_prod.yml config
```
### Puerto ya en uso
**Problema**: Los puertos 3000, 4000 o 5432 están ocupados
**Solución**:
```bash
# Ver qué está usando los puertos
sudo lsof -i :3000
sudo lsof -i :4000
sudo lsof -i :5432
# Detener contenedores anteriores
docker compose -f docker-compose_prod.yml down
```
## 🎯 Verificación Post-Despliegue
Después de un despliegue exitoso, verifica:
1. **Frontend accesible**: https://franciaocupada.martivich.es
2. **API accesible**: https://api.franciaocupada.martivich.es
3. **Contenedores corriendo**:
```bash
docker compose -f docker-compose_prod.yml ps
```
Deberías ver 3 contenedores: `client`, `server`, `db`
4. **Logs sin errores**:
```bash
docker compose -f docker-compose_prod.yml logs --tail=100
```
## 📝 Notas Importantes
- **Rama principal**: El workflow se activa en push a `main` o `master`
- **Etiqueta del runner**: Debe ser `production-ready` (configurada en tu runner)
- **Docker Compose**: Usa `docker-compose_prod.yml` para producción
- **Variables de entorno**: Configuradas en `docker-compose_prod.yml`:
- `NEXT_PUBLIC_API_URL=https://api.franciaocupada.martivich.es`
- `CORS_ORIGIN=https://franciaocupada.martivich.es`
## 🔐 Seguridad
- El workflow usa el token de Gitea automáticamente (`${{ gitea.token }}`)
- No es necesario configurar secrets adicionales para este workflow básico
- Si necesitas secrets (API keys, passwords), agrégalos en:
- Gitea → Repositorio → Settings → Secrets
## 🚀 Próximos Pasos Recomendados
1. **Pruebas automatizadas**: Agregar tests antes del deploy
2. **Notificaciones**: Configurar notificaciones de éxito/fallo
3. **Rollback automático**: Implementar rollback si el deploy falla
4. **Health checks**: Verificar que la app responde correctamente
5. **Backup de DB**: Hacer backup antes de cada deploy

176
DEPLOYMENT-SUMMARY.md Normal file
View File

@@ -0,0 +1,176 @@
# 🎯 Resumen de Configuración CI/CD
## ✅ Lo que hemos hecho
### 1. Workflow de Gitea Actions
- **Archivo**: `.gitea/workflows/deployment.yml`
- **Trigger**: Push a `main` o `master`
- **Runner**: `production-ready` (tu runner configurado)
- **Acciones**:
- ✅ Checkout del código
- ✅ Configuración de Node.js 20
- ✅ Detención de contenedores anteriores
- ✅ Limpieza de imágenes antiguas
- ✅ Construcción de imágenes Docker
- ✅ Despliegue con docker-compose_prod.yml
- ✅ Verificación de estado
- ✅ Logs de debugging
### 2. Documentación
- **CI-CD-README.md**: Guía completa de uso y troubleshooting
- **monitor-deploy.sh**: Script interactivo de monitoreo
### 3. Commit y Push
- ✅ Commit realizado: `273a228`
- ✅ Push a origin/main completado
- ✅ Workflow debería estar ejecutándose ahora
## 🚀 Próximos Pasos
### 1. Verificar que el Workflow se está ejecutando
Abre tu navegador y ve a:
```
http://gitea.local:3000/marti/FranciaOcupada/actions
```
Deberías ver el workflow "CI/CD - Francia Ocupada (La Resistencia)" ejecutándose.
### 2. Monitorear el Despliegue
Desde tu servidor donde está el runner, ejecuta:
```bash
cd /home/marti/Documentos/Gitea/resistencia
./monitor-deploy.sh
```
O manualmente:
```bash
# Ver estado de contenedores
docker compose -f docker-compose_prod.yml ps
# Ver logs en tiempo real
docker compose -f docker-compose_prod.yml logs -f
```
### 3. Verificar el Despliegue
Una vez completado el workflow, verifica:
**Frontend**: https://franciaocupada.martivich.es
**API**: https://api.franciaocupada.martivich.es
## 🔍 Cómo Saber si Funcionó
### En Gitea Web UI
1. Ve a Actions en tu repositorio
2. Busca el workflow más reciente
3. Todos los pasos deberían tener un ✅ verde
4. El último paso mostrará las URLs de la aplicación
### En el Servidor
```bash
# Deberías ver 3 contenedores corriendo
docker compose -f docker-compose_prod.yml ps
# Salida esperada:
# NAME STATUS PORTS
# resistencia-client Up 0.0.0.0:3000->3000/tcp
# resistencia-server Up 0.0.0.0:4000->4000/tcp
# resistencia-db Up 0.0.0.0:5432->5432/tcp
```
## 🧪 Hacer una Prueba de Despliegue
### Opción A: Cambio real
```bash
# Hacer un cambio pequeño
echo "# CI/CD Test" >> README.md
git add README.md
git commit -m "test: Verificación de CI/CD"
git push origin main
```
### Opción B: Commit vacío
```bash
# Trigger sin cambios
git commit --allow-empty -m "test: Trigger CI/CD workflow"
git push origin main
```
### Opción C: Ejecución manual
1. Ve a http://gitea.local:3000/marti/FranciaOcupada/actions
2. Click en el workflow
3. Click en "Run workflow"
4. Selecciona la rama `main`
5. Click en "Run"
## 📊 Diferencias con el Ejemplo Original
### Tu Proyecto vs HolaMundo
| Aspecto | HolaMundo | Francia Ocupada |
|---------|-----------|-----------------|
| **Imagen** | Una sola imagen | 3 servicios (client, server, db) |
| **Herramienta** | `docker build` | `docker compose` |
| **Config** | Dockerfile simple | docker-compose_prod.yml |
| **Puerto** | 8080 | 3000 (client), 4000 (server) |
| **Complejidad** | Baja | Media-Alta |
### Adaptaciones Realizadas
1. **Docker Compose en lugar de Docker directo**
- Usamos `docker compose -f docker-compose_prod.yml` en todos los pasos
2. **Múltiples servicios**
- Cliente (Next.js)
- Servidor (Node.js + Socket.IO)
- Base de datos (PostgreSQL)
3. **Variables de entorno de producción**
- `NEXT_PUBLIC_API_URL=https://api.franciaocupada.martivich.es`
- `CORS_ORIGIN=https://franciaocupada.martivich.es`
4. **Verificación mejorada**
- Comprueba que los 3 contenedores estén corriendo
- Muestra logs de cada servicio
## 🛠️ Troubleshooting Rápido
### El workflow no se ejecuta
- Verifica que el runner esté corriendo: `docker ps | grep gitea-act_runner`
- Verifica la conexión del runner con Gitea
### El workflow falla en el build
- Revisa los logs en Gitea Actions
- Construye manualmente: `docker compose -f docker-compose_prod.yml build`
### Los contenedores no inician
- Verifica puertos disponibles: `sudo lsof -i :3000 :4000 :5432`
- Revisa logs: `docker compose -f docker-compose_prod.yml logs`
### La aplicación no responde
- Espera 30-60 segundos después del deploy
- Verifica que los contenedores estén "Up": `docker compose -f docker-compose_prod.yml ps`
- Revisa logs del cliente y servidor
## 📝 Archivos Creados
```
resistencia/
├── .gitea/
│ └── workflows/
│ └── deployment.yml # Workflow de CI/CD
├── CI-CD-README.md # Documentación detallada
├── monitor-deploy.sh # Script de monitoreo
└── DEPLOYMENT-SUMMARY.md # Este archivo
```
## 🎉 ¡Listo!
Tu proyecto ahora tiene CI/CD automático. Cada vez que hagas push a `main`, se desplegará automáticamente en producción.
**URLs de Verificación**:
- 🌐 Frontend: https://franciaocupada.martivich.es
- 🔌 API: https://api.franciaocupada.martivich.es
- 🔧 Gitea Actions: http://gitea.local:3000/marti/FranciaOcupada/actions

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.

247
NUEVO-ENFOQUE-CICD.md Normal file
View File

@@ -0,0 +1,247 @@
# 🎯 Nuevo Enfoque de CI/CD - Script de Deployment en el Host
## El Problema que Resolvimos
### ❌ Enfoque Anterior (No Funcionaba)
El workflow intentaba ejecutar comandos `docker` directamente dentro del contenedor del runner:
```yaml
- name: Construir Imágenes
run: |
docker compose -f docker-compose_prod.yml build
```
**Problema**: Aunque el socket de Docker estaba montado (`/var/run/docker.sock`), el binario `docker` no estaba disponible dentro del contenedor del runner, causando el error:
```
docker: command not found
```
### ✅ Nuevo Enfoque (Funciona)
Creamos un script `deploy.sh` que se ejecuta **directamente en el host**, donde Docker SÍ está instalado:
```yaml
- name: Ejecutar Deployment
run: |
cd /home/marti/Documentos/Gitea/resistencia
./deploy.sh
```
## Arquitectura del Nuevo Sistema
```
┌─────────────────────────────────────────────────────────────┐
│ GITEA SERVER │
│ - Detecta push a main │
│ - Envía job al runner │
└────────────────┬────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ GITEA RUNNER (Contenedor) │
│ - Instala Node.js │
│ - Hace checkout del código │
│ - Ejecuta deploy.sh en el HOST │
└────────────────┬────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ HOST (Servidor de Producción) │
│ - deploy.sh se ejecuta aquí │
│ - Docker está instalado aquí │
│ - Construye y despliega contenedores │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Client │ │ Server │ │ Database │ │
│ │ (Next.js) │ │ (Node.js) │ │ (PostgreSQL) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Componentes del Sistema
### 1. `deploy.sh` - Script de Deployment en el Host
**Ubicación**: `/home/marti/Documentos/Gitea/resistencia/deploy.sh`
**Responsabilidades**:
- ✅ Actualizar código desde Git
- ✅ Detener contenedores anteriores
- ✅ Limpiar imágenes antiguas
- ✅ Construir nuevas imágenes Docker
- ✅ Desplegar contenedores
- ✅ Verificar que todo funciona
- ✅ Mostrar logs
**Ventajas**:
- Se ejecuta directamente en el host donde Docker está instalado
- Puede ser ejecutado manualmente para debugging: `./deploy.sh`
- Fácil de modificar y probar
- No depende de las limitaciones del runner
### 2. `.gitea/workflows/deployment.yml` - Workflow Simplificado
**Responsabilidades**:
- ✅ Instalar Node.js (necesario para `actions/checkout`)
- ✅ Hacer checkout del código
- ✅ Ejecutar `deploy.sh` en el host
- ✅ Verificar el resultado
**Ventajas**:
- Mucho más simple y mantenible
- Menos propenso a errores
- Fácil de entender y debuggear
## Cómo Funciona
### Flujo Completo
1. **Desarrollador hace push a `main`**
```bash
git push origin main
```
2. **Gitea detecta el push y activa el workflow**
- El runner recibe el job
3. **Runner instala Node.js**
- Necesario para que `actions/checkout` funcione
4. **Runner hace checkout del código**
- Descarga la última versión del repositorio
5. **Runner ejecuta `deploy.sh` en el host**
- El script se ejecuta con acceso completo a Docker del host
6. **`deploy.sh` realiza el deployment**
- Actualiza código
- Construye imágenes
- Despliega contenedores
7. **Verificación final**
- El workflow verifica que los contenedores estén corriendo
## Uso Manual del Script
También puedes ejecutar el deployment manualmente:
```bash
# Conectarte al servidor
ssh usuario@servidor
# Ir al directorio del proyecto
cd /home/marti/Documentos/Gitea/resistencia
# Ejecutar deployment
./deploy.sh
```
Esto es útil para:
- Debugging
- Deployments de emergencia
- Probar cambios antes de hacer commit
## Comparación con el Ejemplo que Funciona
El ejemplo que compartiste usa `runs-on: ubuntu-latest`, que es una imagen de GitHub/Gitea con **todas las herramientas preinstaladas**:
```yaml
jobs:
build:
runs-on: ubuntu-latest # ← Imagen completa con todo instalado
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4 # ← Node.js ya disponible
- run: mvn clean deploy # ← Maven ya disponible
```
Tu runner usa `runs-on: [production-ready]`, que es un **runner personalizado** que:
- ✅ Tiene acceso al Docker del host (vía socket)
- ❌ NO tiene Docker CLI instalado dentro del contenedor
- ❌ NO tiene Node.js preinstalado
- ❌ NO tiene otras herramientas preinstaladas
Por eso necesitamos:
1. Instalar Node.js manualmente
2. Ejecutar un script en el host (donde Docker SÍ está)
## Ventajas del Nuevo Enfoque
1. **Simplicidad**: Un script bash es más fácil de entender que un workflow complejo
2. **Debugging**: Puedes ejecutar `./deploy.sh` manualmente para probar
3. **Flexibilidad**: Fácil modificar el script sin tocar el workflow
4. **Portabilidad**: El mismo script puede usarse en otros sistemas de CI/CD
5. **Confiabilidad**: Se ejecuta en el host donde sabemos que Docker funciona
## Archivos del Sistema
```
resistencia/
├── .gitea/
│ └── workflows/
│ └── deployment.yml # Workflow simplificado
├── deploy.sh # Script de deployment (NUEVO)
├── docker-compose_prod.yml # Configuración de producción
├── CI-CD-README.md # Documentación general
├── TROUBLESHOOTING-CICD.md # Problemas resueltos
└── monitor-deploy.sh # Script de monitoreo
```
## Próximos Pasos
Ahora que el CI/CD funciona, puedes:
1. **Probar el deployment**:
```bash
git commit --allow-empty -m "test: Probar nuevo CI/CD"
git push origin main
```
2. **Monitorear en Gitea**:
- http://gitea.local:3000/marti/FranciaOcupada/actions
3. **Verificar la aplicación**:
- https://franciaocupada.martivich.es
- https://api.franciaocupada.martivich.es
4. **Mejoras futuras**:
- Agregar tests antes del deploy
- Implementar rollback automático
- Agregar notificaciones (Discord, email, etc.)
- Backup de base de datos antes de deploy
- Health checks post-deployment
## Troubleshooting
### Si el workflow falla
1. **Ver logs en Gitea Actions**
2. **Ejecutar manualmente el script**:
```bash
cd /home/marti/Documentos/Gitea/resistencia
./deploy.sh
```
3. **Verificar que Docker funciona en el host**:
```bash
docker ps
docker compose version
```
### Si los contenedores no inician
```bash
# Ver logs
docker compose -f docker-compose_prod.yml logs
# Reiniciar servicios
docker compose -f docker-compose_prod.yml restart
# Reconstruir desde cero
docker compose -f docker-compose_prod.yml down
docker compose -f docker-compose_prod.yml up -d --build
```
## Conclusión
Este nuevo enfoque es más simple, más confiable y más fácil de mantener. En lugar de luchar contra las limitaciones del runner, aprovechamos que el runner puede ejecutar scripts en el host donde Docker ya está instalado y funcionando.
**¡El CI/CD ahora debería funcionar correctamente!** 🎉

132
README.md
View File

@@ -1,93 +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 MISSION)
-Lobby con creación/unión de partidas
-Intro con música y animaciones
-Revelación de roles con cartas
- ✅ Roll call con avatares
- ✅ Votación de líder con timer de 10 segundos
- ✅ Selección de equipo por el líder
-Votación de equipo
- ✅ Votación de misión (éxito/sabotaje solo para espías)
- ✅ Componente VotingTimer funcionando
#### 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.
### ❌ Pendiente de Implementar (Solo UI)
### 🎨 Últimas Mejoras (v1.4 - Diciembre 2025)
1. **Fase MISSION_REVEAL**: Pantalla que muestra las cartas de votación una a una (backend listo)
2. **Fase MISSION_RESULT**: Pantalla resumen con resultado de la misión (backend listo)
3. **Histórico de Misiones**: Componente visual en esquina mostrando misiones completadas (backend listo)
- 🔄 **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.
## 🚀 Cómo Ejecutar
## 🚀 Despliegue y Ejecución
### 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. **Fases MISSION_REVEAL y MISSION_RESULT**: No tienen UI implementada. El juego se quedará en esa fase si se completa una misión.
**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. Implementar UI para MISSION_REVEAL (mostrar cartas una a una con animación)
2. Implementar UI para MISSION_RESULT (pantalla resumen)
3. Agregar histórico de misiones visual
4. Optimizar rendimiento del servidor
## 🎯 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)

261
RESUMEN-FINAL.md Normal file
View File

@@ -0,0 +1,261 @@
# ✅ Resumen Final - CI/CD Configurado
## 🎉 ¡CI/CD Configurado Exitosamente!
Has configurado un sistema completo de CI/CD para tu proyecto "Francia Ocupada" usando Gitea Actions.
## 📦 Lo que Hemos Creado
### 1. Script de Deployment (`deploy.sh`)
- ✅ Script bash que se ejecuta en el host
- ✅ Maneja todo el proceso de deployment
- ✅ Puede ejecutarse manualmente para debugging
### 2. Workflow de Gitea Actions (`.gitea/workflows/deployment.yml`)
- ✅ Se activa automáticamente en push a `main`
- ✅ Instala Node.js
- ✅ Hace checkout del código
- ✅ Ejecuta el script de deployment
### 3. Documentación Completa
-`CI-CD-README.md` - Guía general de uso
-`TROUBLESHOOTING-CICD.md` - Problemas resueltos
-`NUEVO-ENFOQUE-CICD.md` - Explicación del enfoque actual
-`DEPLOYMENT-SUMMARY.md` - Resumen de la configuración
-`monitor-deploy.sh` - Script de monitoreo
-`useful-commands.sh` - Comandos útiles
## 🚀 Cómo Usar el CI/CD
### Deployment Automático
Simplemente haz push a la rama `main`:
```bash
git add .
git commit -m "feat: Nueva funcionalidad"
git push origin main
```
El workflow se ejecutará automáticamente y desplegará tu aplicación.
### Deployment Manual
Si necesitas hacer un deployment sin hacer cambios:
```bash
# Opción 1: Commit vacío
git commit --allow-empty -m "deploy: Trigger deployment"
git push origin main
# Opción 2: Ejecutar el script directamente en el servidor
ssh usuario@servidor
cd /home/marti/Documentos/Gitea/resistencia
./deploy.sh
```
## 📊 Monitoreo
### Ver el Workflow en Gitea
```
http://gitea.local:3000/marti/FranciaOcupada/actions
```
### Verificar Estado de Contenedores
```bash
cd /home/marti/Documentos/Gitea/resistencia
docker compose -f docker-compose_prod.yml ps
```
### Ver Logs
```bash
# Todos los logs
docker compose -f docker-compose_prod.yml logs
# Logs en tiempo real
docker compose -f docker-compose_prod.yml logs -f
# Script de monitoreo interactivo
./monitor-deploy.sh
```
## 🌐 URLs de la Aplicación
Después del deployment, tu aplicación estará disponible en:
- **Frontend**: https://franciaocupada.martivich.es
- **API**: https://api.franciaocupada.martivich.es
## 🔧 Configuración del Runner
Tu runner está configurado correctamente:
```yaml
services:
gitea-runner:
image: gitea/act_runner:latest
container_name: gitea-act_runner
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- GITEA_INSTANCE_URL=http://gitea.local:3000
- GITEA_RUNNER_NAME=runner-01-ci-vm
- GITEA_RUNNER_LABELS=production-ready:host
- GITEA_RUNNER_REGISTRATION_TOKEN=jzLi65hxB1Rt2RgIGFglXB5RjW9ggCbq9UFX3NrS
```
## 📝 Archivos Importantes
```
resistencia/
├── .gitea/
│ └── workflows/
│ └── deployment.yml # ← Workflow de CI/CD
├── deploy.sh # ← Script de deployment (ejecutable)
├── docker-compose_prod.yml # ← Configuración de producción
├── monitor-deploy.sh # ← Monitoreo interactivo
├── useful-commands.sh # ← Comandos de referencia
└── Documentación:
├── CI-CD-README.md # ← Guía general
├── TROUBLESHOOTING-CICD.md # ← Problemas y soluciones
├── NUEVO-ENFOQUE-CICD.md # ← Explicación técnica
├── DEPLOYMENT-SUMMARY.md # ← Resumen de configuración
└── RESUMEN-FINAL.md # ← Este archivo
```
## 🎯 Próximos Pasos Recomendados
### 1. Probar el CI/CD
```bash
# Hacer un cambio pequeño
echo "# Test CI/CD" >> README.md
git add README.md
git commit -m "test: Verificar CI/CD"
git push origin main
# Monitorear en Gitea
# http://gitea.local:3000/marti/FranciaOcupada/actions
```
### 2. Verificar el Deployment
```bash
# Esperar 2-3 minutos para que complete
# Luego verificar:
curl -I https://franciaocupada.martivich.es
curl -I https://api.franciaocupada.martivich.es
```
### 3. Mejoras Futuras
#### Tests Automatizados
Agregar tests antes del deployment:
```yaml
- name: Run Tests
run: |
cd /home/marti/Documentos/Gitea/resistencia
npm test
```
#### Notificaciones
Configurar notificaciones de éxito/fallo (Discord, email, etc.)
#### Rollback Automático
Implementar rollback si el deployment falla
#### Staging Environment
Crear un ambiente de staging para probar antes de producción
#### Health Checks
Verificar que la aplicación responde correctamente después del deploy
#### Backup de Base de Datos
Hacer backup automático antes de cada deployment
## 🐛 Troubleshooting Rápido
### El workflow falla
1. Ver logs en Gitea Actions
2. Ejecutar `./deploy.sh` manualmente
3. Revisar `TROUBLESHOOTING-CICD.md`
### Los contenedores no inician
```bash
docker compose -f docker-compose_prod.yml logs
docker compose -f docker-compose_prod.yml restart
```
### La aplicación no responde
```bash
# Verificar contenedores
docker compose -f docker-compose_prod.yml ps
# Ver logs
docker compose -f docker-compose_prod.yml logs -f
# Reiniciar
docker compose -f docker-compose_prod.yml restart
```
## 📚 Comandos Útiles de Referencia
```bash
# Ver estado
docker compose -f docker-compose_prod.yml ps
# Ver logs
docker compose -f docker-compose_prod.yml logs -f
# Reiniciar
docker compose -f docker-compose_prod.yml restart
# Reconstruir
docker compose -f docker-compose_prod.yml up -d --build
# Detener
docker compose -f docker-compose_prod.yml down
# Deployment manual
./deploy.sh
# Monitoreo interactivo
./monitor-deploy.sh
# Ver todos los comandos
./useful-commands.sh
```
## 🎓 Lecciones Aprendidas
1. **Runners personalizados son diferentes a `ubuntu-latest`**
- No tienen todas las herramientas preinstaladas
- Necesitan configuración específica
2. **El socket de Docker no es suficiente**
- Necesitas también el binario `docker` instalado
- O ejecutar scripts en el host donde Docker está instalado
3. **Los scripts en el host son más simples**
- Más fáciles de debuggear
- Más flexibles
- Más confiables
4. **La documentación es clave**
- Ayuda a entender problemas futuros
- Facilita el mantenimiento
- Útil para otros desarrolladores
## 🎉 ¡Felicidades!
Has configurado exitosamente un sistema completo de CI/CD para tu aplicación. Ahora cada vez que hagas push a `main`, tu aplicación se desplegará automáticamente en producción.
**¡A disfrutar del deployment automático!** 🚀
---
**Última actualización**: 2025-12-13
**Versión**: 1.0
**Estado**: ✅ Funcional

255
SESION-CICD-RESUMEN.md Normal file
View File

@@ -0,0 +1,255 @@
# 📚 Resumen de la Sesión CI/CD - Puntos de Aprendizaje
## 🎯 Lo que Intentamos Lograr
Configurar CI/CD automático para desplegar "Francia Ocupada" en producción usando Gitea Actions.
## 🔍 Problemas Encontrados
### 1. **Runner Personalizado vs Runner Estándar**
**Tu configuración**:
```yaml
runs-on: [production-ready] # Runner personalizado
```
**Ejemplo que funciona**:
```yaml
runs-on: ubuntu-latest # Runner estándar de GitHub/Gitea
```
**Diferencia clave**:
- `ubuntu-latest` tiene TODAS las herramientas preinstaladas (docker, node, git, etc.)
- Tu runner personalizado es un contenedor vacío que solo tiene acceso al socket de Docker
### 2. **Socket de Docker ≠ Docker CLI**
Tu runner tiene:
- ✅ Acceso al socket de Docker (`/var/run/docker.sock`)
- ❌ NO tiene el binario `docker` instalado
Por eso todos los comandos `docker` fallan con "command not found".
### 3. **Comandos dentro del Runner vs Comandos en el Host**
El runner ejecuta comandos **dentro de su contenedor**, no directamente en el host.
```
┌─────────────────────────────────┐
│ HOST (Servidor) │
│ - Docker instalado ✅ │
│ - Proyecto en /home/marti/... │
│ │
│ ┌───────────────────────────┐ │
│ │ RUNNER (Contenedor) │ │
│ │ - Docker NO instalado ❌ │ │
│ │ - Comandos se ejecutan │ │
│ │ AQUÍ, no en el host │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
```
## 📋 Soluciones Intentadas
### Intento 1: Usar variables de contexto de Gitea ❌
- No funcionó: Variables mal interpoladas
### Intento 2: Reordenar pasos (Node.js antes de checkout) ❌
- No funcionó: Seguía faltando Node.js
### Intento 3: Instalar Node.js manualmente ✅
- Funcionó: Node.js instalado correctamente
### Intento 4: Especificar `shell: bash` ❌
- No funcionó: Docker seguía sin encontrarse
### Intento 5: Instalar Docker CLI en el runner ❌
- No intentado: Demasiado complejo
### Intento 6: Script de deployment en el host ⚠️
- Parcialmente: Script creado pero falta ejecutarlo correctamente
### Intento 7: Eliminar copia con rsync ⏸️
- En progreso: Simplificado pero no probado
## 🎓 Conceptos Clave Aprendidos
### 1. **Etiquetas de Runner**
```yaml
GITEA_RUNNER_LABELS=production-ready:host
```
- `production-ready`: Nombre de la etiqueta
- `:host`: Indica que debe ejecutar en modo "host" (acceso al Docker del host)
### 2. **Volúmenes de Docker**
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
Esto da acceso al **socket** de Docker, pero NO instala el **cliente** de Docker.
### 3. **Diferencia entre CI y CD**
- **CI (Continuous Integration)**: Construir, probar, validar código
- **CD (Continuous Deployment)**: Desplegar a producción
Tu runner está configurado para CD (deployment), no para CI (build).
## 🛠️ Opciones para Continuar
### Opción 1: Usar un Runner Estándar (Más Fácil) ⭐
**Ventajas**:
- Todo preinstalado
- Funciona como el ejemplo que compartiste
- Menos configuración
**Desventajas**:
- Necesitas configurar acceso SSH al servidor de producción
- El deployment se hace remotamente
**Cómo hacerlo**:
```yaml
jobs:
deploy:
runs-on: ubuntu-latest # ← Cambiar a esto
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
run: |
ssh user@servidor 'cd /path/to/project && ./deploy.sh'
```
### Opción 2: Configurar el Runner con Docker Preinstalado (Medio)
Crear una imagen personalizada del runner con Docker ya instalado.
**Dockerfile del runner**:
```dockerfile
FROM gitea/act_runner:latest
RUN apt-get update && apt-get install -y docker-ce-cli
```
### Opción 3: Deployment Manual con Git Hooks (Más Simple) ⭐⭐
Usar Git hooks en el servidor para auto-desplegar cuando haces push.
**En el servidor**:
```bash
# .git/hooks/post-receive
#!/bin/bash
cd /home/marti/Documentos/Gitea/resistencia
git pull
./deploy.sh
```
### Opción 4: Usar Portainer o Watchtower (Automático)
Herramientas que detectan cambios en imágenes Docker y auto-actualizan.
### Opción 5: Continuar con el Enfoque Actual (Más Complejo)
Necesitarías:
1. Entender mejor cómo el runner accede al filesystem del host
2. Configurar permisos correctos
3. Posiblemente usar SSH desde el runner al host
## 📚 Recursos para Estudiar
### Gitea Actions
- [Documentación oficial de Gitea Actions](https://docs.gitea.com/usage/actions/overview)
- [Act Runner GitHub](https://github.com/nektos/act)
- [Diferencias entre GitHub Actions y Gitea Actions](https://docs.gitea.com/usage/actions/comparison)
### Docker en CI/CD
- [Docker-in-Docker (DinD)](https://www.docker.com/blog/docker-can-now-run-within-docker/)
- [Docker socket mounting](https://docs.docker.com/engine/reference/commandline/dockerd/#daemon-socket-option)
### Alternativas
- [Drone CI](https://www.drone.io/) - CI/CD más simple
- [Jenkins](https://www.jenkins.io/) - Más potente pero complejo
- [GitLab CI/CD](https://docs.gitlab.com/ee/ci/) - Similar a Gitea Actions
## 💡 Mi Recomendación
Para tu caso específico, te recomendaría **Opción 1 o Opción 3**:
### Opción 1: Runner Estándar + SSH
```yaml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Production
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /home/marti/Documentos/Gitea/resistencia
git pull
./deploy.sh
```
### Opción 3: Git Hook (Sin CI/CD)
```bash
# En el servidor, en el repositorio bare de Gitea
# /path/to/gitea/data/gitea-repositories/marti/FranciaOcupada.git/hooks/post-receive
#!/bin/bash
cd /home/marti/Documentos/Gitea/resistencia
git pull origin main
./deploy.sh
```
## 📝 Estado Actual del Proyecto
### ✅ Lo que SÍ funciona:
- Script `deploy.sh` - Listo para usar
- Docker Compose configurado
- Aplicación funcional en local
### ⚠️ Lo que NO funciona:
- Workflow de Gitea Actions con runner personalizado
- Ejecución automática de deployment
### 🎯 Lo que tienes listo para usar:
```bash
# Deployment manual (esto SÍ funciona)
cd /home/marti/Documentos/Gitea/resistencia
./deploy.sh
```
## 🚀 Próximos Pasos Sugeridos
1. **Estudiar** los recursos mencionados arriba
2. **Decidir** qué opción se adapta mejor a tus necesidades
3. **Probar** el deployment manual mientras tanto: `./deploy.sh`
4. **Considerar** si realmente necesitas CI/CD automático o si el deployment manual es suficiente
## 📦 Archivos Útiles Creados
Aunque el CI/CD automático no funcionó, estos archivos son útiles:
-`deploy.sh` - Script de deployment manual (funciona)
-`monitor-deploy.sh` - Monitoreo de contenedores
-`useful-commands.sh` - Comandos de referencia
- ✅ Documentación completa del proceso
## 💭 Reflexión Final
CI/CD con runners personalizados es complejo. No es un fallo tuyo no haberlo logrado en la primera sesión. Muchos equipos profesionales tardan días o semanas en configurar correctamente su CI/CD.
**Alternativa práctica**: Mientras estudias más sobre el tema, puedes usar deployment manual con `./deploy.sh`, que es perfectamente válido para proyectos pequeños/medianos.
---
**Fecha**: 2025-12-13
**Estado**: En pausa para estudio
**Próximo paso**: Decidir entre las opciones propuestas arriba

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)

233
TROUBLESHOOTING-CICD.md Normal file
View File

@@ -0,0 +1,233 @@
# 🔧 Troubleshooting CI/CD - Problemas Resueltos
## Resumen de Problemas Encontrados y Soluciones
Durante la configuración del CI/CD con Gitea Actions, encontramos varios problemas que fueron resueltos paso a paso. Este documento sirve como referencia para futuros problemas similares.
---
## Problema 1: Error en el Checkout - Variables de Contexto
### ❌ Error
```
%!t(string=http://gitea.local:3000)
%!t(string=***)
%!t(string=marti/FranciaOcupada)
```
### 🔍 Causa
El workflow intentaba usar variables de contexto de Gitea (`${{ gitea.server_url }}`, `${{ gitea.repository }}`, `${{ gitea.token }}`) en el paso de checkout, pero estas no se interpolaban correctamente.
### ✅ Solución
Simplificar el checkout eliminando los parámetros innecesarios:
```yaml
# ❌ ANTES (No funcionaba)
- name: 🚀 Checkout del Código
uses: actions/checkout@v4
with:
server-url: ${{ gitea.server_url }}
repository: ${{ gitea.repository }}
token: ${{ gitea.token }}
fetch-depth: 0
# ✅ DESPUÉS (Funciona)
- name: 🚀 Checkout del Código
uses: actions/checkout@v4
with:
fetch-depth: 0
```
**Commit**: `609033b` - "fix: Corregir checkout en workflow de Gitea Actions"
---
## Problema 2: Node.js No Encontrado
### ❌ Error
```
Cannot find: node in PATH
```
### 🔍 Causa
El runner de Gitea Actions no tiene Node.js preinstalado, y las acciones de GitHub (como `actions/checkout@v4` y `actions/setup-node@v4`) requieren Node.js para ejecutarse.
### ✅ Solución
Instalar Node.js manualmente usando comandos del sistema ANTES de cualquier acción de GitHub:
```yaml
- name: 📦 Instalar Node.js
shell: bash
run: |
echo "Verificando si Node.js está instalado..."
if ! command -v node &> /dev/null; then
echo "Node.js no encontrado, instalando..."
# Detectar el sistema operativo
if [ -f /etc/debian_version ]; then
# Debian/Ubuntu
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
elif [ -f /etc/redhat-release ]; then
# RedHat/CentOS/Fedora
curl -fsSL https://rpm.nodesource.com/setup_20.x | bash -
yum install -y nodejs
elif [ -f /etc/alpine-release ]; then
# Alpine Linux
apk add --no-cache nodejs npm
else
# Fallback a nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm install 20
fi
else
echo "Node.js ya está instalado: $(node --version)"
fi
# Verificar instalación
node --version
npm --version
```
**Commit**: `7c9ff53` - "feat: Instalación manual de Node.js en el runner"
---
## Problema 3: Scripts No Encontrados (2.sh, 3.sh, 4.sh)
### ❌ Error
```
/root/.cache/act/6d07dd4849690bae/act/workflow/2.sh: line 3: docker: command not found
/root/.cache/act/6d07dd4849690bae/act/workflow/3.sh: line 3: docker: command not found
/root/.cache/act/6d07dd4849690bae/act/workflow/4.sh: line 3: docker: command not found
```
### 🔍 Causa
El runner de Gitea Actions (basado en `act`) estaba creando archivos temporales (2.sh, 3.sh, 4.sh) para ejecutar los bloques `run:`, pero no especificaba correctamente el shell a usar, causando que los comandos no se ejecutaran en el contexto correcto.
### ✅ Solución
Especificar explícitamente `shell: bash` en todos los pasos que usan bloques `run:`:
```yaml
# ❌ ANTES (No funcionaba)
- name: 🛑 Detener Contenedores Anteriores
run: |
echo "Deteniendo contenedores existentes..."
docker compose -f docker-compose_prod.yml down || true
# ✅ DESPUÉS (Funciona)
- name: 🛑 Detener Contenedores Anteriores
shell: bash
run: |
echo "Deteniendo contenedores existentes..."
docker compose -f docker-compose_prod.yml down || true
```
**Commit**: `134460a` - "fix: Especificar shell bash explícitamente en todos los pasos"
---
## Orden Correcto de los Pasos
El orden final correcto de los pasos es:
1. **Instalar Node.js** (con `shell: bash`)
2. **Checkout del Código** (usando `actions/checkout@v4`)
3. **Detener Contenedores Anteriores** (con `shell: bash`)
4. **Limpiar Imágenes Antiguas** (con `shell: bash`)
5. **Construir Imágenes Docker** (con `shell: bash`)
6. **Desplegar Aplicación** (con `shell: bash`)
7. **Verificar Despliegue** (con `shell: bash`)
8. **Mostrar Logs Recientes** (con `shell: bash` e `if: always()`)
---
## Lecciones Aprendidas
### 1. Diferencias entre GitHub Actions y Gitea Actions
- Gitea Actions usa `act` bajo el capó, que tiene algunas diferencias con GitHub Actions
- No todas las variables de contexto funcionan igual
- El runner puede no tener las mismas herramientas preinstaladas
### 2. Especificar el Shell es Importante
- Siempre especifica `shell: bash` cuando uses bloques `run:` con múltiples líneas
- Esto evita problemas de interpretación de comandos
### 3. Node.js es Requerido
- Muchas acciones de GitHub requieren Node.js
- En runners de Gitea, puede que necesites instalarlo manualmente
- Instálalo ANTES de usar cualquier acción de GitHub
### 4. El Runner Necesita Acceso a Docker
- Asegúrate de que el runner tenga acceso al socket de Docker del host
- En tu configuración: `-v /var/run/docker.sock:/var/run/docker.sock`
- La etiqueta debe ser `production-ready:host` para acceso al Docker del host
---
## Configuración del Runner
Tu configuración del runner que funciona:
```yaml
services:
gitea-runner:
image: gitea/act_runner:latest
container_name: gitea-act_runner
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- GITEA_INSTANCE_URL=http://gitea.local:3000
- GITEA_RUNNER_NAME=runner-01-ci-vm
- GITEA_RUNNER_LABELS=production-ready:host
- GITEA_RUNNER_REGISTRATION_TOKEN=jzLi65hxB1Rt2RgIGFglXB5RjW9ggCbq9UFX3NrS
```
**Puntos clave**:
- `production-ready:host` - La etiqueta `:host` es crucial para acceso a Docker
- Volumen del socket de Docker montado
- URL de Gitea correcta
---
## Verificación Post-Despliegue
Después de un despliegue exitoso, verifica:
```bash
# 1. Estado de contenedores
docker compose -f docker-compose_prod.yml ps
# 2. Logs recientes
docker compose -f docker-compose_prod.yml logs --tail=50
# 3. Acceso a la aplicación
curl -I https://franciaocupada.martivich.es
curl -I https://api.franciaocupada.martivich.es
# 4. Estado del runner
docker ps | grep gitea-act_runner
```
---
## Recursos Útiles
- **Gitea Actions Docs**: https://docs.gitea.com/usage/actions/overview
- **Act Runner**: https://gitea.com/gitea/act_runner
- **GitHub Actions Syntax**: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
---
## Próximas Mejoras Recomendadas
1. **Caché de Node.js**: Cachear la instalación de Node.js para acelerar futuros builds
2. **Tests Automatizados**: Agregar tests antes del deploy
3. **Rollback Automático**: Implementar rollback si el deploy falla
4. **Notificaciones**: Configurar notificaciones de éxito/fallo (email, Discord, etc.)
5. **Health Checks**: Verificar que la app responde correctamente antes de dar por exitoso el deploy
6. **Backup de DB**: Hacer backup de la base de datos antes de cada deploy
7. **Staging Environment**: Crear un ambiente de staging para probar antes de producción

View File

@@ -0,0 +1,56 @@
# Bitácora del Proyecto: Francia Ocupada (La Resistencia)
**Fecha:** 10 de Diciembre de 2025
**Versión actual:** v1.0
---
## 1. Resumen de Actividades Realizadas
### 🔧 Corrección de Entornos (Desarrollo vs Producción)
- **Problema detectado:** La aplicación en producción (`franciaocupada.martivich.es`) intentaba conectarse a `localhost`, provocando advertencias de seguridad en el navegador y fallos de conexión desde redes externas.
- **Solución implementada:**
- Se han separado los archivos de orquestación de Docker:
1. `docker-compose.yml`: Configurado para desarrollo local (`localhost:3000` y `localhost:4000`).
2. `docker-compose_prod.yml`: Nuevo archivo optimizado para producción.
- Se configuraron las variables de entorno `NEXT_PUBLIC_API_URL` y `CORS_ORIGIN` para apuntar al dominio público en el archivo de producción.
### 🌐 Configuración de Proxy Inverso (Nginx Proxy Manager)
- Se ha definido la arquitectura correcta para el despliegue tras un Nginx Proxy Manager:
- **Frontend:** `franciaocupada.martivich.es` -> Redirige al contenedor cliente (puerto 3000).
- **Backend (API):** `api.franciaocupada.martivich.es` -> Redirige al contenedor servidor (puerto 4000).
- Se habilitó soporte para **WebSockets** y **SSL** (Force SSL, HTTP/2) en el proxy.
### 📦 Gestión de Versiones (Git & Gitea)
- **Release v1.0:**
- Se creó un commit de consolidación ("Release v1.0").
- Se etiquetó la versión con `git tag v1.0`.
- Se normalizó la rama principal renombrándola de `fix-gameboard` a `main`.
- **Configuración del Repositorio Remoto:**
- Se cambió el nombre del repositorio en Gitea a **FranciaOcupada**.
- Se configuró la autenticación mediante **HTTPS + Token Personal** (más didáctico para la docencia) en lugar de SSH.
- Se configuró el `credential.helper store` para evitar reintroducir credenciales constantemente.
- Se añadió `GiteaToken.txt` al `.gitignore` por seguridad.
- Se realizó la subida inicial completa (`git push -u origin main` y `git push origin --tags`).
---
## 2. Próximos Pasos (Roadmap)
### 🚀 Implementación de CI/CD (Gitea Actions)
El siguiente gran objetivo es automatizar el ciclo de vida del desarrollo.
#### Pipeline de Integración Continua (Local/Dev)
- **Objetivo:** Ejecutar pruebas automáticas al hacer push para asegurar la calidad del código.
- **Herramientas:** Gitea Actions (compatible con sintaxis GitHub Actions).
#### Pipeline de Despliegue Continuo (Producción)
- **Objetivo:** Desplegar automáticamente la nueva versión en el servidor cuando se publique un Tag (ej: `v1.1`).
- **Tareas pendientes:**
1. Habilitar/Verificar Runners en Gitea.
2. Crear el archivo workflow `.gitea/workflows/deploy.yaml`.
3. Configurar la construcción de imágenes Docker y su subida al Gitea Container Registry.
4. Automatizar el comando de despliegue en el servidor remoto vía SSH (`docker compose -f ... up -d`).
### 🛠️ Mejoras Futuras
- Revisar logs de producción para posibles ajustes de rendimiento.
- Documentar el proceso de alta de nuevos desarrolladores (alumnos).

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

5870
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "resistencia-client",
"version": "0.1.0",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,7 +19,7 @@
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/node": "^20.19.27",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 KiB

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 KiB

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 691 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 KiB

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);
@@ -100,6 +166,7 @@ export default function Home() {
<div className="z-10 bg-black/80 p-8 rounded border border-white/20 max-w-2xl w-full mx-4 backdrop-blur-md">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-yellow-500 mb-2 uppercase tracking-widest">Sala de Espera</h2>
<p className="text-xl font-bold text-white mb-1">{gameState.roomName}</p>
<p className="text-gray-400">Operación en curso. Esperando activación...</p>
</div>
@@ -148,8 +215,9 @@ export default function Home() {
return (
<GameBoard
gameState={gameState}
currentPlayerId={socket.id}
currentPlayerId={socket.id || ''}
actions={actions}
fullPlayerName={fullPlayerName}
/>
);
}
@@ -177,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>
@@ -229,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>
@@ -244,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>
)}
@@ -382,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
@@ -25,6 +27,12 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
const [missionVote, setMissionVote] = useState<boolean | null>(null);
const [expandedMission, setExpandedMission] = useState<number | null>(null);
// Estado para controlar el colapso del panel de jugadores
const [isPlayersCollapsed, setIsPlayersCollapsed] = useState(true);
// Estado para controlar el colapso del historial de misiones
const [isHistoryCollapsed, setIsHistoryCollapsed] = useState(true);
// Timer para avanzar automáticamente en REVEAL_ROLE
useEffect(() => {
@@ -53,14 +61,10 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
// Estado para controlar cuándo mostrar el tablero
const [showBoard, setShowBoard] = useState(false);
// Mostrar tablero 7 segundos después de MISSION_RESULT
// Mostrar tablero durante MISSION_RESULT
useEffect(() => {
if (gameState.phase === GamePhase.MISSION_RESULT) {
setShowBoard(true);
const timer = setTimeout(() => {
setShowBoard(false);
}, 7000); // 7 segundos
return () => clearTimeout(timer);
} else {
setShowBoard(false);
}
@@ -91,11 +95,11 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
// Coordenadas porcentuales de los hexágonos de misión en el mapa
const missionCoords = [
{ left: '12%', top: '55%' }, // Misión 1
{ left: '28%', top: '15%' }, // Misión 2
{ left: '52%', top: '25%' }, // Misión 3
{ left: '42%', top: '70%' }, // Misión 4
{ left: '82%', top: '40%' }, // Misión 5
{ left: '18%', top: '60%' }, // Misión 1 - Abajo izquierda
{ left: '25%', top: '18%' }, // Misión 2 - Arriba izquierda
{ left: '50%', top: '75%' }, // Misión 3 - Abajo centro
{ left: '50%', top: '30%' }, // Misión 4 - Centro
{ left: '80%', top: '45%' }, // Misión 5 - Derecha
];
// Nombres de las misiones
@@ -119,8 +123,8 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
<div className="absolute inset-0 bg-black/40" />
</div>
<h1 className="z-10 text-5xl font-bold uppercase tracking-[0.3em] mb-8 text-yellow-500 drop-shadow-lg text-center">
Guerra Total
<h1 className="z-10 text-[2.55rem] font-bold uppercase tracking-[0.3em] mb-8 text-yellow-500 drop-shadow-lg text-center">
Traición en París
</h1>
{/* Audio Auto-Play - Solo para el host */}
@@ -213,7 +217,7 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
<motion.div
drag="y"
dragConstraints={{ top: -300, bottom: 0 }}
dragElastic={0.2}
dragElastic={0.5}
dragSnapToOrigin={true}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98, cursor: 'grabbing' }}
@@ -237,7 +241,7 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
// FASE ROLL CALL
if (gameState.phase === 'roll_call' as any) {
return (
<div className="relative w-full h-screen flex flex-col items-center justify-center bg-black overflow-hidden text-white font-mono">
<div className="relative w-full h-screen flex flex-col bg-black overflow-hidden text-white font-mono">
<div className="absolute inset-0 z-0">
@@ -245,20 +249,24 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
<div className="absolute inset-0 bg-black/70" />
</div>
<div className="z-10 w-full max-w-5xl px-4">
<h2 className="text-3xl text-center mb-12 uppercase tracking-[0.2em] text-gray-300 border-b border-gray-600 pb-4">
{/* --- 1. SECCIÓN SUPERIOR: TÍTULO (20-25% altura) --- */}
<div className="relative z-10 w-full h-[20vh] flex items-center justify-center px-4 border-b border-gray-600/50 bg-black/20 backdrop-blur-sm">
<h2 className="text-2xl md:text-3xl lg:text-4xl text-center uppercase tracking-[0.2em] text-gray-300 drop-shadow-lg">
Pasando Lista...
</h2>
</div>
{/* --- 2. SECCIÓN INFERIOR: JUGADORES (Resto de altura) --- */}
<div className="relative z-10 w-full flex-1 overflow-y-auto p-4 flex flex-col items-center">
{isHost && (
<audio
src="/assets/audio/Rondas.ogg"
src="/assets/audio/Rondas.mp3"
autoPlay
onEnded={() => actions.finishRollCall()} // Host avanza cuando acaba audio
/>
)}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
<div className="w-full max-w-6xl grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-8 justify-items-center content-center py-4">
{gameState.players.map((p, i) => {
return (
<motion.div
@@ -266,9 +274,10 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.3 }} // Aparecen uno a uno
className="flex flex-col items-center gap-3"
className="flex flex-col items-center gap-1 md:gap-3 w-full"
>
<div className="w-32 h-32 rounded-full border-4 border-gray-400 overflow-hidden relative shadow-2xl bg-black">
{/* Avatar Responsive */}
<div className="w-20 h-20 md:w-32 md:h-32 rounded-full border-2 md:border-4 border-gray-400 overflow-hidden relative shadow-2xl bg-black">
<Image
src={`/assets/images/characters/${p.avatar}`}
alt="Avatar"
@@ -276,7 +285,8 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
className="object-cover grayscale contrast-125"
/>
</div>
<div className="bg-black/80 px-4 py-1 rounded border border-white/20 text-xl font-bold text-yellow-500 uppercase">
{/* Nombre Responsive */}
<div className="bg-black/80 px-2 py-0.5 md:px-4 md:py-1 rounded border border-white/20 text-xs md:text-xl font-bold text-yellow-500 uppercase text-center w-full truncate max-w-[120px] md:max-w-none">
{p.name}
</div>
</motion.div>
@@ -289,8 +299,16 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
}
return (
<div className="relative w-full h-screen flex flex-col items-center overflow-hidden">
<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
src={
@@ -307,7 +325,8 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
<div className="absolute inset-0 bg-black/60" />
</div>
<div className="relative z-10 w-full flex flex-col items-center">
{/* Contenedor principal */}
<div className="relative z-10 w-full flex flex-col items-center pb-32">
{/* --- MAPA TÁCTICO (TABLERO) O CARTA DE MISIÓN O ASSASSIN_PHASE --- */}
{/* No mostrar el tablero en fases de victoria */}
@@ -326,8 +345,8 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
{/* Overlay oscuro para mejorar legibilidad */}
<div className="absolute inset-0 bg-black/40" />
{/* Título sobre la imagen */}
<div className="absolute top-4 left-0 right-0 flex flex-col items-center z-10">
{/* Título sobre la imagen - centrado verticalmente */}
<div className="absolute top-1/3 left-0 right-0 flex flex-col items-center z-10">
<h1 className="text-5xl font-bold text-red-600 mb-2 drop-shadow-[0_4px_8px_rgba(0,0,0,0.9)]">
¡ÚLTIMA OPORTUNIDAD!
</h1>
@@ -409,16 +428,6 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
</div>
);
})}
{/* TRACK DE VOTOS FALLIDOS */}
<div className="absolute bottom-[5%] left-[2%] bg-black/60 p-2 rounded border border-white/20">
<div className="text-[10px] text-gray-300 uppercase mb-1 text-center">Votos Rechazados</div>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<div key={i} className={`w-3 h-3 rounded-full border border-gray-500 ${i < gameState.failedVotesCount ? 'bg-red-500' : 'bg-transparent'}`} />
))}
</div>
</div>
</>
) : (
/* CARTA DE MISIÓN CON TÍTULO */
@@ -472,13 +481,13 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
{gameState.leaderVotes?.[currentPlayerId] === undefined ? (
<div className="flex gap-8">
<button onClick={() => actions.voteLeader(true)} className="group">
<div className="w-40 h-60 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-green-500 transition-all transform group-hover:-translate-y-4 relative overflow-hidden">
<div className="w-32 h-32 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-green-500 transition-all transform group-hover:-translate-y-4 relative overflow-hidden">
<Image src="/assets/images/tokens/accept_leader.png" alt="Accept Leader" fill className="object-contain" />
</div>
<span className="block text-center text-white mt-2 font-bold bg-green-600 px-2 rounded uppercase tracking-widest">ACEPTAR</span>
</button>
<button onClick={() => actions.voteLeader(false)} className="group">
<div className="w-40 h-60 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-red-500 transition-all transform group-hover:-translate-y-4 relative overflow-hidden">
<div className="w-32 h-32 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-red-500 transition-all transform group-hover:-translate-y-4 relative overflow-hidden">
<Image src="/assets/images/tokens/deny_leader.png" alt="Deny Leader" fill className="object-contain" />
</div>
<span className="block text-center text-white mt-2 font-bold bg-red-600 px-2 rounded uppercase tracking-widest">RECHAZAR</span>
@@ -502,27 +511,31 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
className="flex flex-col items-center gap-6 w-full max-w-4xl"
>
{/* Información del líder */}
<div className="bg-yellow-600/90 text-black p-4 rounded-lg shadow-xl border-4 border-yellow-400 w-full text-center">
<div className="flex items-center justify-center gap-3">
<Image src="/assets/images/tokens/token_leader.png" alt="Leader" width={40} height={40} />
<div>
<div className="text-sm uppercase tracking-wider font-bold">Líder Actual</div>
<div className="text-2xl font-bold">
{gameState.players.find(p => p.id === gameState.currentLeaderId)?.name || 'Desconocido'}
{/* Información del líder - SOLO para NO líderes */}
{!isLeader && (
<div className="bg-yellow-600/90 text-black p-2 rounded-lg shadow-xl border-4 border-yellow-400 w-full text-center mb-2">
<div className="flex items-center justify-center gap-3">
<div>
<div className="text-xs uppercase tracking-wider font-bold">Líder Actual</div>
<div className="text-xl font-bold">
{gameState.players.find(p => p.id === gameState.currentLeaderId)?.name || 'Desconocido'}
</div>
</div>
</div>
<Image src="/assets/images/tokens/token_leader.png" alt="Leader" width={40} height={40} />
</div>
</div>
)}
{/* Mensaje para el líder o para los demás */}
<div className="bg-paper-bg text-black p-6 rounded shadow-2xl rotate-1 w-full text-center">
<h2 className="text-2xl font-bold font-mono mb-2 uppercase text-resistance-blue">
{/* Mensaje para el líder o para los demás */}
<div className="bg-paper-bg text-black p-4 md:p-6 rounded shadow-2xl w-full text-center">
<h2 className="text-xl md:text-2xl font-bold font-mono mb-2 uppercase text-resistance-blue">
{isLeader ? '🎯 TU TURNO: ELIGE TU EQUIPO' : '⏳ ESPERANDO AL LÍDER...'}
</h2>
<p className="mb-4 font-serif italic text-gray-700">
Se necesitan <span className="font-bold text-red-700 text-xl">{currentQuestSize} agentes</span> para la misión #{gameState.currentRound}.
</p>
{isLeader && (
<p className="mb-4 font-serif italic text-gray-700">
Se necesitan <span className="font-bold text-red-700 text-xl">{currentQuestSize} agentes</span> para la misión #{gameState.currentRound}.
</p>
)}
{/* Contador de seleccionados */}
{isLeader && (
@@ -574,19 +587,23 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
</div>
</div>
{!currentPlayer?.hasVoted ? (
{gameState.teamVotes[currentPlayerId] === undefined ? (
<div className="flex gap-8">
<button onClick={() => actions.voteTeam(true)} className="group">
<div className="w-32 h-48 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-green-500 transition-all transform group-hover:-translate-y-4">
<Image src="/assets/images/tokens/vote_approve.png" alt="Approve" width={100} height={100} />
<div className="w-32 h-48 bg-white rounded-lg shadow-xl flex flex-col items-center justify-center border-2 border-blue-500 group-hover:border-blue-400 group-hover:shadow-blue-500/50 transition-all transform group-hover:-translate-y-4 relative overflow-hidden p-2">
<span className="text-blue-600 font-bold text-sm uppercase tracking-wider mb-1 z-10">Éxito</span>
<div className="relative w-full h-full flex items-center justify-center">
<Image src="/assets/images/tokens/vote_approve.png" alt="Approve" fill className="object-contain" />
</div>
</div>
<span className="block text-center text-white mt-2 font-bold bg-green-600 px-2 rounded">APROBAR</span>
</button>
<button onClick={() => actions.voteTeam(false)} className="group">
<div className="w-32 h-48 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-red-500 transition-all transform group-hover:-translate-y-4">
<Image src="/assets/images/tokens/vote_reject.png" alt="Reject" width={100} height={100} />
<div className="w-32 h-48 bg-white rounded-lg shadow-xl flex flex-col items-center justify-center border-2 border-red-500 group-hover:border-red-400 group-hover:shadow-red-500/50 transition-all transform group-hover:-translate-y-4 relative overflow-hidden p-2">
<span className="text-red-600 font-bold text-sm uppercase tracking-wider mb-1 z-10">Fracaso</span>
<div className="relative w-full h-full flex items-center justify-center">
<Image src="/assets/images/tokens/vote_reject.png" alt="Reject" fill className="object-contain" />
</div>
</div>
<span className="block text-center text-white mt-2 font-bold bg-red-600 px-2 rounded">RECHAZAR</span>
</button>
</div>
) : (
@@ -602,26 +619,19 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
{gameState.phase === GamePhase.MISSION && (
<motion.div
key="mission"
className="fixed inset-0 flex items-center justify-center bg-black/90 z-50"
className="fixed inset-0 flex items-start justify-center bg-black/90 z-50 pt-20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{gameState.proposedTeam.includes(currentPlayerId) ? (
<div className="flex flex-col items-center gap-8 w-full max-w-6xl px-4">
<h2 className="text-4xl md:text-5xl font-bold text-white mb-4 drop-shadow-2xl text-center uppercase tracking-wider animate-pulse">
🎯 ¡ESTÁS EN LA MISIÓN!
<div className="flex flex-col items-center gap-4 w-full max-w-6xl px-4">
<h2 className="text-4xl md:text-5xl font-bold text-white mb-2 drop-shadow-2xl text-center uppercase tracking-wider animate-pulse">
🎯 REALIZA LA MISIÓN
</h2>
<p className="text-white text-xl mb-4 text-center">
Elige el resultado de tu participación
<p className="text-white text-xl mb-2 text-center">
Elige si quieres un éxito o un fracaso
</p>
{/* DEBUG INFO - TEMPORAL */}
<div className="text-xs text-gray-400 bg-black/50 p-2 rounded mb-4">
Debug: Tu ID: {currentPlayerId} | Equipo: [{gameState.proposedTeam.join(', ')}]
<br />
Tu facción: {currentPlayer?.faction || 'UNDEFINED'} | Rol: {currentPlayer?.role || 'UNDEFINED'}
</div>
{/* Cartas en orden aleatorio */}
<div className="flex gap-12 flex-wrap justify-center">
{cardOrder ? (
@@ -633,12 +643,12 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
disabled={missionVote !== null}
>
<motion.div
className="w-64 h-96 bg-gradient-to-br from-blue-600 to-blue-900 rounded-2xl shadow-2xl border-4 border-blue-400 flex flex-col items-center justify-center p-6 transform transition-all hover:scale-110 hover:rotate-3 hover:shadow-blue-500/50"
className="w-32 h-48 bg-gradient-to-br from-blue-600 to-blue-900 rounded-2xl shadow-2xl border-4 border-blue-400 flex flex-col items-center justify-center p-4 transform transition-all hover:scale-110 hover:rotate-3 hover:shadow-blue-500/50"
whileHover={{ scale: 1.1, rotate: 3 }}
whileTap={{ scale: 0.95 }}
>
<Image src="/assets/images/tokens/vote_approve.png" alt="Success" width={180} height={180} className="drop-shadow-2xl" />
<span className="mt-6 text-white font-bold text-2xl tracking-widest uppercase">ÉXITO</span>
<Image src="/assets/images/tokens/vote_approve.png" alt="Success" width={80} height={80} className="drop-shadow-2xl" />
<span className="mt-2 text-white font-bold text-lg tracking-widest uppercase">ÉXITO</span>
</motion.div>
</button>
@@ -650,12 +660,12 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
disabled={missionVote !== null}
>
<motion.div
className="w-64 h-96 bg-gradient-to-br from-red-600 to-red-900 rounded-2xl shadow-2xl border-4 border-red-400 flex flex-col items-center justify-center p-6 transform transition-all hover:scale-110 hover:-rotate-3 hover:shadow-red-500/50"
className="w-32 h-48 bg-gradient-to-br from-red-600 to-red-900 rounded-2xl shadow-2xl border-4 border-red-400 flex flex-col items-center justify-center p-4 transform transition-all hover:scale-110 hover:-rotate-3 hover:shadow-red-500/50"
whileHover={{ scale: 1.1, rotate: -3 }}
whileTap={{ scale: 0.95 }}
>
<Image src="/assets/images/tokens/vote_reject.png" alt="Fail" width={180} height={180} className="drop-shadow-2xl" />
<span className="mt-6 text-white font-bold text-2xl tracking-widest uppercase">SABOTAJE</span>
<Image src="/assets/images/tokens/vote_reject.png" alt="Fail" width={80} height={80} className="drop-shadow-2xl" />
<span className="mt-2 text-white font-bold text-lg tracking-widest uppercase">SABOTAJE</span>
</motion.div>
</button>
)}
@@ -670,12 +680,12 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
disabled={missionVote !== null}
>
<motion.div
className="w-64 h-96 bg-gradient-to-br from-red-600 to-red-900 rounded-2xl shadow-2xl border-4 border-red-400 flex flex-col items-center justify-center p-6 transform transition-all hover:scale-110 hover:-rotate-3 hover:shadow-red-500/50"
className="w-32 h-48 bg-gradient-to-br from-red-600 to-red-900 rounded-2xl shadow-2xl border-4 border-red-400 flex flex-col items-center justify-center p-4 transform transition-all hover:scale-110 hover:-rotate-3 hover:shadow-red-500/50"
whileHover={{ scale: 1.1, rotate: -3 }}
whileTap={{ scale: 0.95 }}
>
<Image src="/assets/images/tokens/vote_reject.png" alt="Fail" width={180} height={180} className="drop-shadow-2xl" />
<span className="mt-6 text-white font-bold text-2xl tracking-widest uppercase">SABOTAJE</span>
<Image src="/assets/images/tokens/vote_reject.png" alt="Fail" width={80} height={80} className="drop-shadow-2xl" />
<span className="mt-2 text-white font-bold text-lg tracking-widest uppercase">SABOTAJE</span>
</motion.div>
</button>
)}
@@ -687,12 +697,12 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
disabled={missionVote !== null}
>
<motion.div
className="w-64 h-96 bg-gradient-to-br from-blue-600 to-blue-900 rounded-2xl shadow-2xl border-4 border-blue-400 flex flex-col items-center justify-center p-6 transform transition-all hover:scale-110 hover:rotate-3 hover:shadow-blue-500/50"
className="w-32 h-48 bg-gradient-to-br from-blue-600 to-blue-900 rounded-2xl shadow-2xl border-4 border-blue-400 flex flex-col items-center justify-center p-4 transform transition-all hover:scale-110 hover:rotate-3 hover:shadow-blue-500/50"
whileHover={{ scale: 1.1, rotate: 3 }}
whileTap={{ scale: 0.95 }}
>
<Image src="/assets/images/tokens/vote_approve.png" alt="Success" width={180} height={180} className="drop-shadow-2xl" />
<span className="mt-6 text-white font-bold text-2xl tracking-widest uppercase">ÉXITO</span>
<Image src="/assets/images/tokens/vote_approve.png" alt="Success" width={80} height={80} className="drop-shadow-2xl" />
<span className="mt-2 text-white font-bold text-lg tracking-widest uppercase">ÉXITO</span>
</motion.div>
</button>
</>
@@ -790,9 +800,39 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
</AnimatePresence>
</div>
{/* JUGADORES (TIENDA DE CAMPAÑA) */}
<div className="z-10 w-full overflow-x-auto pb-4">
<div className="flex justify-center gap-4 min-w-max px-4">
{/* JUGADORES - POSICIONADOS ABSOLUTAMENTE EN EL FONDO */}
<motion.div
className="fixed bottom-0 left-0 right-0 z-50 bg-black/80 border-t border-white/10 backdrop-blur-md"
initial={false}
animate={{
y: isPlayersCollapsed ? '100%' : '0%'
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
{/* Botón de colapso/expansión */}
<div className="absolute -top-10 left-1/2 transform -translate-x-1/2">
<button
onClick={() => setIsPlayersCollapsed(!isPlayersCollapsed)}
className="bg-gradient-to-b from-yellow-600 to-yellow-700 hover:from-yellow-500 hover:to-yellow-600 text-white rounded-t-lg px-6 py-2 shadow-lg border-2 border-yellow-500 border-b-0 transition-all hover:shadow-yellow-500/50 flex items-center gap-2"
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
animate={{ rotate: isPlayersCollapsed ? 180 : 0 }}
transition={{ duration: 0.3 }}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 9l-7 7-7-7" />
</motion.svg>
<span className="text-sm font-bold uppercase tracking-wider">
{isPlayersCollapsed ? 'Mostrar' : 'Ocultar'}
</span>
</button>
</div>
<div className="w-full px-4 py-2 flex flex-wrap items-center justify-center gap-4">
{gameState.players.map((player) => {
const isSelected = selectedTeam.includes(player.id);
const isMe = player.id === currentPlayerId;
@@ -805,16 +845,15 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
key={player.id}
onClick={() => isLeader && gameState.phase === GamePhase.TEAM_BUILDING && toggleTeamSelection(player.id)}
className={`
relative flex flex-col items-center cursor-pointer transition-all duration-300
${isSelected ? 'scale-110' : 'scale-100 opacity-80 hover:opacity-100'}
`}
relative flex flex-col items-center cursor-pointer transition-all duration-300 group
${isSelected ? 'scale-110 z-10' : 'scale-100 opacity-70 hover:opacity-100 hover:scale-105'}
`}
>
{/* Avatar */}
<div className={`
w-16 h-16 rounded-full border-2 overflow-hidden relative shadow-lg bg-black
${isSelected ? 'border-yellow-400 ring-4 ring-yellow-400/30' : 'border-gray-400'}
${gameState.currentLeaderId === player.id ? 'ring-2 ring-white' : ''}
`}>
<div className="relative rounded-full border-2 overflow-hidden shadow-lg bg-black transition-all w-16 h-16
${isSelected ? 'border-yellow-400 ring-4 ring-yellow-400/30 shadow-yellow-400/20' : 'border-gray-500 group-hover:border-gray-300'}
${gameState.currentLeaderId === player.id ? 'ring-2 ring-white' : ''}
">
<Image
src={avatarSrc}
alt={player.name}
@@ -824,7 +863,7 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
{/* Icono de Líder */}
{gameState.currentLeaderId === player.id && (
<div className="absolute bottom-0 right-0 bg-yellow-500 rounded-full p-1 w-6 h-6 flex items-center justify-center text-xs text-black font-bold border border-white z-10">
<div className="absolute bottom-0 right-0 bg-yellow-500 rounded-full p-1 w-6 h-6 flex items-center justify-center text-[10px] text-black font-bold border border-white z-20 shadow-sm">
L
</div>
)}
@@ -836,65 +875,108 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
gameState.phase === 'mission_reveal' as any ||
gameState.phase === 'mission_result' as any
) && (
<div className="absolute top-0 left-0 bg-green-500 rounded-full p-1 w-6 h-6 flex items-center justify-center text-xs text-white font-bold border border-white z-10">
<div className="absolute top-0 left-0 bg-green-500 rounded-full p-1 w-6 h-6 flex items-center justify-center text-xs text-white font-bold border border-white z-20">
</div>
)}
</div>
{/* Nombre */}
<span className={`mt-2 text-xs font-mono px-2 py-0.5 rounded ${isMe ? 'bg-blue-600 text-white' : 'bg-black/50 text-gray-300'}`}>
<span className={`
mt-1 text-xs font-mono px-2 py-0.5 rounded shadow-sm whitespace-nowrap max-w-[100px] truncate
${isMe ? 'bg-blue-600 text-white font-bold' : 'bg-black/60 text-gray-300 border border-white/10'}
`}>
{player.name}
</span>
</div>
);
})}
</div>
</div>
</motion.div>
{/* HISTÓRICO DE MISIONES (Esquina superior derecha) */}
{gameState.missionHistory.length > 0 && (
<div className="absolute top-4 right-4 bg-black/80 p-3 rounded-lg border border-white/20 backdrop-blur-sm">
<div className="text-[10px] text-gray-400 uppercase mb-2 text-center font-bold tracking-wider">Historial</div>
<div className="flex gap-2">
{gameState.missionHistory.map((mission, idx) => {
const isExpanded = expandedMission === idx;
<motion.div
className="fixed top-[60px] right-0 z-50"
initial={false}
animate={{
x: isHistoryCollapsed ? '0%' : '0%'
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
{/* Botón de colapso/expansión */}
<motion.button
onClick={() => setIsHistoryCollapsed(!isHistoryCollapsed)}
className="absolute top-0 bg-gradient-to-l from-yellow-600 to-yellow-700 hover:from-yellow-500 hover:to-yellow-600 text-white rounded-l-lg px-2 py-3 shadow-lg border-2 border-yellow-500 border-r-0 transition-all hover:shadow-yellow-500/50 flex items-center"
initial={false}
animate={{
right: isHistoryCollapsed ? '0px' : '100%'
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
animate={{ rotate: isHistoryCollapsed ? 0 : 180 }}
transition={{ duration: 0.3 }}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M15 19l-7-7 7-7" />
</motion.svg>
</motion.button>
return (
<div key={idx} className="relative">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold border-2 cursor-pointer transition-all hover:scale-110 ${mission.isSuccess
{/* Panel del historial */}
<motion.div
className="bg-black/80 p-3 rounded-lg border border-white/20 backdrop-blur-sm"
initial={false}
animate={{
x: isHistoryCollapsed ? '100%' : '0%'
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<div className="text-[10px] text-gray-400 uppercase mb-2 text-center font-bold tracking-wider">Historial</div>
<div className="flex gap-2">
{gameState.missionHistory.map((mission, idx) => {
const isExpanded = expandedMission === idx;
return (
<div key={idx} className="relative">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold border-2 cursor-pointer transition-all hover:scale-110 ${mission.isSuccess
? 'bg-blue-600 border-blue-400 text-white'
: 'bg-red-600 border-red-400 text-white'
} ${isExpanded ? 'ring-2 ring-yellow-400' : ''}`}
title={`Misión ${mission.round}: ${mission.isSuccess ? 'Éxito' : 'Fracaso'} (${mission.successes} ${mission.fails})`}
onClick={(e) => {
e.stopPropagation();
console.log('Click en misión', idx, 'Estado actual:', expandedMission);
setExpandedMission(isExpanded ? null : idx);
}}
>
{mission.round}
</div>
{/* Lista de participantes */}
{isExpanded && (
<div className="absolute top-10 right-0 bg-black/95 p-2 rounded border border-white/30 min-w-max z-[100]">
{mission.team.map((playerId) => {
const player = gameState.players.find(p => p.id === playerId);
return (
<div key={playerId} className="text-xs text-white whitespace-nowrap">
{player?.name || playerId}
</div>
);
})}
} ${isExpanded ? 'ring-2 ring-yellow-400 relative z-[60]' : ''}`}
title={`Misión ${mission.round}: ${mission.isSuccess ? 'Éxito' : 'Fracaso'} (${mission.successes} ${mission.fails})`}
onClick={(e) => {
e.stopPropagation();
console.log('Click en misión', idx);
setExpandedMission(prev => prev === idx ? null : idx);
}}
>
{mission.round}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Lista de participantes */}
{isExpanded && (
<div className="absolute top-10 right-0 bg-black/95 p-2 rounded border border-white/30 min-w-max z-[100]">
{mission.team.map((playerId) => {
const player = gameState.players.find(p => p.id === playerId);
return (
<div key={playerId} className="text-xs text-white whitespace-nowrap">
{player?.name || playerId}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</motion.div>
</motion.div>
)}
</div>
</div>
@@ -914,7 +996,7 @@ function VotingTimer() {
}, [timeLeft]);
return (
<div className="absolute top-4 right-4 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

@@ -1,711 +0,0 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image';
import { GameState, GamePhase, Player, GAME_CONFIG } from '../../../shared/types';
interface GameBoardProps {
gameState: GameState;
currentPlayerId: string;
actions: any;
}
export default function GameBoard({ gameState, currentPlayerId, actions }: GameBoardProps) {
const [selectedTeam, setSelectedTeam] = useState<string[]>([]);
// Hooks para FASE REVEAL ROLE
const [revealCard, setRevealCard] = useState(false);
// Timer para avanzar automáticamente en REVEAL_ROLE
useEffect(() => {
if (gameState.phase === 'reveal_role' as any) {
const timer = setTimeout(() => {
actions.finishReveal();
}, 10000);
return () => clearTimeout(timer);
}
}, [gameState.phase, actions]);
const currentPlayer = gameState.players.find(p => p.id === currentPlayerId);
const isLeader = currentPlayer?.isLeader;
const config = GAME_CONFIG[gameState.players.length as keyof typeof GAME_CONFIG];
const currentQuestSize = config?.quests[gameState.currentRound - 1];
// Manejar selección de equipo
const toggleTeamSelection = (playerId: string) => {
if (selectedTeam.includes(playerId)) {
setSelectedTeam(selectedTeam.filter(id => id !== playerId));
} else {
if (selectedTeam.length < currentQuestSize) {
setSelectedTeam([...selectedTeam, playerId]);
}
}
};
// Coordenadas porcentuales de los hexágonos de misión en el mapa
const missionCoords = [
{ left: '12%', top: '55%' }, // Misión 1
{ left: '28%', top: '15%' }, // Misión 2
{ left: '52%', top: '25%' }, // Misión 3
{ left: '42%', top: '70%' }, // Misión 4
{ left: '82%', top: '40%' }, // Misión 5
];
// --- UI/Efectos para FASES TEMPRANAS ---
const isHost = gameState.hostId === currentPlayerId;
// FASE INTRO
if (gameState.phase === 'intro' as any) {
return (
<div className="relative w-full h-screen flex flex-col items-center justify-center bg-black overflow-hidden text-white">
<div className="absolute inset-0 z-0">
<Image src="/assets/images/ui/bg_intro.png" alt="Battlefield" fill className="object-cover" />
<div className="absolute inset-0 bg-black/40" />
</div>
<h1 className="z-10 text-5xl font-bold uppercase tracking-[0.3em] mb-8 text-yellow-500 drop-shadow-lg text-center">
Guerra Total
</h1>
{/* Audio Auto-Play */}
<audio
src="/assets/audio/Intro.ogg"
autoPlay
onEnded={() => isHost && actions.finishIntro()}
/>
{isHost && (
<button
onClick={() => actions.finishIntro()}
className="z-10 bg-white/20 hover:bg-white/40 border border-white px-6 py-2 rounded text-sm uppercase tracking-widest backdrop-blur-sm transition-all"
>
Omitir Introducción
</button>
)}
</div>
);
}
// FASE REVEAL ROLE NO HOOKS HERE
if (gameState.phase === 'reveal_role' as any) {
// Determinar imagen basada en el rol
// Mapeo básico:
// Merlin -> good_merlin.png
// Percival -> good_percival.png
// Servant -> good_soldier_X.png (random)
// Assassin -> evil_assassin.png
// Morgana -> evil_morgana.png
// Mordred -> evil_mordred.png
// Oberon -> evil_oberon.png
// Minion -> evil_minion_X.png
let roleImage = '/assets/images/characters/good_soldier_1.png'; // Default
const role = currentPlayer?.role;
if (role === 'merlin') roleImage = '/assets/images/characters/good_merlin.png';
else if (role === 'assassin') roleImage = '/assets/images/characters/evil_assassin.png';
else if (role === 'percival') roleImage = '/assets/images/characters/good_percival.png';
else if (role === 'morgana') roleImage = '/assets/images/characters/evil_morgana.png';
else if (role === 'mordred') roleImage = '/assets/images/characters/evil_mordred.png';
else if (role === 'oberon') roleImage = '/assets/images/characters/evil_oberon.png';
else if (role === 'loyal_servant') {
// Random soldier 1-5
const idx = (currentPlayerId.charCodeAt(0) % 5) + 1;
roleImage = `/assets/images/characters/good_soldier_${idx}.png`;
}
else if (role === 'minion') {
// Random minion 1-3
const idx = (currentPlayerId.charCodeAt(0) % 3) + 1;
roleImage = `/assets/images/characters/evil_minion_${idx}.png`;
}
return (
<div className="relative w-full h-screen flex flex-col items-center justify-center bg-black overflow-hidden text-white font-mono">
{/* FONDO (Mismo que Roll Call) */}
<div className="absolute inset-0 z-0">
<Image src="/assets/images/ui/bg_roll_call.png" alt="Resistance HQ" fill className="object-cover" />
<div className="absolute inset-0 bg-black/70" />
</div>
<div className="z-10 flex flex-col items-center gap-8">
<h2 className="text-2xl uppercase tracking-[0.2em] text-gray-300">
Tu Identidad Secreta
</h2>
<p className="text-sm text-gray-400 mb-4 animate-pulse">
Desliza hacia arriba para revelar
</p>
<div className="relative w-64 h-96 perspective-1000">
{/* Carta Revelada (Fondo) */}
<div className="absolute inset-0 w-full h-full rounded-xl overflow-hidden shadow-2xl border-4 border-yellow-600 bg-gray-900 flex items-center justify-center">
<Image
src={roleImage}
alt="Role"
fill
className="object-cover"
/>
<div className="absolute bottom-0 w-full bg-black/80 text-center py-2 font-bold text-yellow-500 uppercase">
{role?.replace('_', ' ')}
</div>
</div>
{/* Reverso de Carta (Draggable) */}
<motion.div
drag="y"
dragConstraints={{ top: -300, bottom: 0 }}
dragElastic={0.2}
onDragEnd={(e, info) => {
// Reducir umbral a -50 para facilitar
if (info.offset.y < -50) {
setRevealCard(true);
}
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98, cursor: 'grabbing' }}
animate={revealCard ? { y: -1000, opacity: 0 } : { y: 0, opacity: 1 }}
className="absolute inset-0 w-full h-full rounded-xl overflow-hidden shadow-2xl z-20 cursor-grab active:cursor-grabbing hover:ring-2 hover:ring-white/50 transition-all"
>
<Image
src="/assets/images/characters/card_back.png"
alt="Card Back"
fill
className="object-cover pointer-events-none" // Importante: pointer-events-none en la imagen para que no capture el drag
/>
</motion.div>
</div>
</div>
</div>
);
}
// FASE ROLL CALL
if (gameState.phase === 'roll_call' as any) {
return (
<div className="relative w-full h-screen flex flex-col items-center justify-center bg-black overflow-hidden text-white font-mono">
<div className="absolute inset-0 z-0">
<Image src="/assets/images/ui/bg_roll_call.png" alt="Resistance HQ" fill className="object-cover" />
<div className="absolute inset-0 bg-black/70" />
</div>
<div className="z-10 w-full max-w-5xl px-4">
<h2 className="text-3xl text-center mb-12 uppercase tracking-[0.2em] text-gray-300 border-b border-gray-600 pb-4">
Pasando Lista...
</h2>
{isHost && (
<audio
src="/assets/audio/Rondas.ogg"
autoPlay
onEnded={() => actions.finishRollCall()} // Host avanza cuando acaba audio
/>
)}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
{gameState.players.map((p, i) => {
// Asignar avatar determinista basado en charCode
const avatarIdx = (p.name.length % 3) + 1;
return (
<motion.div
key={p.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.3 }} // Aparecen uno a uno
className="flex flex-col items-center gap-3"
>
<div className="w-32 h-32 rounded-full border-4 border-gray-400 overflow-hidden relative shadow-2xl bg-black">
<Image
src={`/assets/images/characters/avatar_${avatarIdx}.png`}
alt="Avatar"
fill
className="object-cover grayscale contrast-125"
/>
</div>
<div className="bg-black/80 px-4 py-1 rounded border border-white/20 text-xl font-bold text-yellow-500 uppercase">
{p.name}
</div>
</motion.div>
);
})}
</div>
</div>
</div>
);
}
return (
<div className="relative w-full h-screen flex flex-col items-center overflow-hidden">
<div className="absolute inset-0 z-0 opacity-40">
<Image src="/assets/images/ui/bg_game.png" alt="Game Background" fill className="object-cover" />
<div className="absolute inset-0 bg-black/60" />
</div>
<div className="relative z-10 w-full flex flex-col items-center">
{/* --- MAPA TÁCTICO (TABLERO) --- */}
<div className="relative w-full max-w-5xl aspect-video mt-4 shadow-2xl border-4 border-gray-800 rounded-lg overflow-hidden bg-[#2a2a2a]">
<Image
src="/assets/images/ui/board_map.jpg"
alt="Tactical Map"
fill
className="object-contain"
/>
{/* TOKENS SOBRE EL MAPA */}
{missionCoords.map((coord, idx) => {
const result = gameState.questResults[idx];
const isCurrent = gameState.currentRound === idx + 1;
return (
<div
key={idx}
className="absolute w-[12%] aspect-square flex items-center justify-center transform -translate-x-1/2 -translate-y-1/2"
style={{ left: coord.left, top: coord.top }}
>
{/* Marcador de Ronda Actual */}
{isCurrent && (
<motion.div
layoutId="round-marker"
className="absolute inset-0 z-10"
initial={{ scale: 1.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<Image
src="/assets/images/tokens/marker_round.png"
alt="Current Round"
fill
className="object-contain drop-shadow-lg"
/>
</motion.div>
)}
{/* Resultado de Misión (Éxito/Fracaso) */}
{result === true && (
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
className="absolute inset-0 z-20"
>
<Image src="/assets/images/tokens/marker_score_blue.png" alt="Success" fill className="object-contain drop-shadow-lg" />
</motion.div>
)}
{result === false && (
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
className="absolute inset-0 z-20"
>
<Image src="/assets/images/tokens/marker_score_red.png" alt="Fail" fill className="object-contain drop-shadow-lg" />
</motion.div>
)}
</div>
);
})}
{/* TRACK DE VOTOS FALLIDOS (Pequeño indicador en la esquina inferior izquierda del mapa) */}
<div className="absolute bottom-[5%] left-[2%] bg-black/60 p-2 rounded border border-white/20">
<div className="text-[10px] text-gray-300 uppercase mb-1 text-center">Votos Rechazados</div>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<div key={i} className={`w-3 h-3 rounded-full border border-gray-500 ${i < gameState.failedVotesCount ? 'bg-red-500' : 'bg-transparent'}`} />
))}
</div>
</div>
</div>
{/* --- ÁREA DE JUEGO (CARTAS Y ACCIONES) --- */}
<div className="flex-1 w-full max-w-6xl relative mt-4 px-4">
<AnimatePresence mode="wait">
{/* FASE: VOTACIÓN DE LÍDER */}
{gameState.phase === 'vote_leader' as any && (
<motion.div
key="vote-leader"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6"
>
<div className="bg-black/80 p-4 rounded text-white text-center border border-yellow-500/50 relative">
<h3 className="text-xl font-mono mb-2 text-yellow-500 uppercase tracking-widest">
Confirmar Líder
</h3>
<div className="text-2xl font-bold mb-2">
¿Aceptas a <span className="text-yellow-400">{gameState.players.find(p => p.id === gameState.currentLeaderId)?.name}</span> como Líder?
</div>
{/* Timer */}
{!gameState.leaderVotes?.[currentPlayerId] && (
<VotingTimer onTimeout={() => actions.voteLeader(null)} />
)}
</div>
{gameState.leaderVotes?.[currentPlayerId] === undefined ? (
<div className="flex gap-8">
<button onClick={() => actions.voteLeader(true)} className="group">
<div className="w-40 h-60 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-green-500 transition-all transform group-hover:-translate-y-4 relative overflow-hidden">
<Image src="/assets/images/tokens/accept_leader.png" alt="Accept Leader" fill className="object-contain" />
</div>
<span className="block text-center text-white mt-2 font-bold bg-green-600 px-2 rounded uppercase tracking-widest">ACEPTAR</span>
</button>
<button onClick={() => actions.voteLeader(false)} className="group">
<div className="w-40 h-60 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-red-500 transition-all transform group-hover:-translate-y-4 relative overflow-hidden">
<Image src="/assets/images/tokens/deny_leader.png" alt="Deny Leader" fill className="object-contain" />
</div>
<span className="block text-center text-white mt-2 font-bold bg-red-600 px-2 rounded uppercase tracking-widest">RECHAZAR</span>
</button>
</div>
) : (
<div className="text-white text-xl font-mono animate-pulse bg-black/50 px-6 py-3 rounded-full border border-white/20">
VOTO REGISTRADO. ESPERANDO AL RESTO...
</div>
)}
</motion.div>
)}
{/* FASE: CONSTRUCCIÓN DE EQUIPO */}
{gameState.phase === GamePhase.TEAM_BUILDING && (
<motion.div
key="team-building"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="bg-paper-bg text-black p-6 rounded shadow-2xl rotate-1 max-w-md w-full text-center"
>
<h2 className="text-2xl font-bold font-mono mb-2 uppercase text-resistance-blue">
{isLeader ? 'TU TURNO: ELIGE EQUIPO' : `ESPERANDO AL LÍDER...`}
</h2>
<p className="mb-4 font-serif italic text-gray-700">
Se necesitan <span className="font-bold text-red-700">{currentQuestSize} agentes</span> para esta misión.
</p>
{isLeader && (
<button
onClick={() => actions.proposeTeam(selectedTeam)}
disabled={selectedTeam.length !== currentQuestSize}
className="w-full bg-resistance-blue text-white font-bold py-3 px-4 rounded hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-widest"
>
Proponer Equipo
</button>
)}
</motion.div>
)}
{/* FASE: VOTACIÓN DE EQUIPO */}
{gameState.phase === GamePhase.VOTING_TEAM && (
<motion.div
key="voting"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center gap-6"
>
<div className="bg-black/80 p-4 rounded text-white text-center border border-white/20">
<h3 className="text-xl font-mono mb-2 text-yellow-500">PROPUESTA DE MISIÓN</h3>
<div className="flex gap-2 justify-center">
{gameState.proposedTeam.map(id => {
const p = gameState.players.find(pl => pl.id === id);
return (
<div key={id} className="bg-white/10 px-3 py-1 rounded text-sm">
{p?.name}
</div>
);
})}
</div>
</div>
{!currentPlayer?.hasVoted ? (
<div className="flex gap-8">
<button onClick={() => actions.voteTeam(true)} className="group">
<div className="w-32 h-48 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-green-500 transition-all transform group-hover:-translate-y-4">
<Image src="/assets/images/tokens/vote_approve.png" alt="Approve" width={100} height={100} />
</div>
<span className="block text-center text-white mt-2 font-bold bg-green-600 px-2 rounded">APROBAR</span>
</button>
<button onClick={() => actions.voteTeam(false)} className="group">
<div className="w-32 h-48 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-red-500 transition-all transform group-hover:-translate-y-4">
<Image src="/assets/images/tokens/vote_reject.png" alt="Reject" width={100} height={100} />
</div>
<span className="block text-center text-white mt-2 font-bold bg-red-600 px-2 rounded">RECHAZAR</span>
</button>
</div>
) : (
<div className="text-white text-xl font-mono animate-pulse">
VOTO REGISTRADO. ESPERANDO AL RESTO...
</div>
)}
</motion.div>
)}
{/* FASE: MISIÓN */}
{gameState.phase === GamePhase.MISSION && (
<motion.div key="mission" className="text-center">
{gameState.proposedTeam.includes(currentPlayerId) ? (
<div className="flex flex-col items-center gap-6">
<h2 className="text-3xl font-bold text-white mb-4 drop-shadow-lg">¡ESTÁS EN LA MISIÓN!</h2>
<div className="flex gap-8">
<button onClick={() => actions.voteMission(true)} className="group">
<div className="w-40 h-60 bg-blue-900 rounded-lg shadow-2xl border-2 border-blue-400 flex flex-col items-center justify-center p-4 transform transition-transform hover:scale-105">
<Image src="/assets/images/tokens/mission_success.png" alt="Success" width={120} height={120} />
<span className="mt-4 text-blue-200 font-bold tracking-widest">ÉXITO</span>
</div>
</button>
{/* Solo los malos pueden sabotear */}
{currentPlayer?.faction === 'spies' && (
<button onClick={() => actions.voteMission(false)} className="group">
<div className="w-40 h-60 bg-red-900 rounded-lg shadow-2xl border-2 border-red-400 flex flex-col items-center justify-center p-4 transform transition-transform hover:scale-105">
<Image src="/assets/images/tokens/mission_fail.png" alt="Fail" width={120} height={120} />
<span className="mt-4 text-red-200 font-bold tracking-widest">SABOTAJE</span>
</div>
</button>
)}
</div>
</div>
) : (
<div className="text-white text-2xl font-mono bg-black/50 p-6 rounded">
La misión está en curso...<br />
<span className="text-sm text-gray-400">Rezando por el éxito.</span>
</div>
)}
</motion.div>
)}
{/* FASE: REVELACIÓN DE CARTAS */}
{gameState.phase === 'mission_reveal' as any && (
<MissionReveal
votes={gameState.revealedVotes || []}
onComplete={() => isHost && actions.finishMissionReveal()}
/>
)}
{/* FASE: RESULTADO DE MISIÓN */}
{gameState.phase === 'mission_result' as any && (
<MissionResult
gameState={gameState}
onContinue={() => isHost && actions.finishMissionResult()}
/>
)}
</AnimatePresence>
</div>
{/* JUGADORES (TIENDA DE CAMPAÑA) */}
<div className="z-10 w-full overflow-x-auto pb-4">
<div className="flex justify-center gap-4 min-w-max px-4">
{gameState.players.map((player) => {
const isSelected = selectedTeam.includes(player.id);
const isMe = player.id === currentPlayerId;
// Avatar logic
const avatarSrc = `/assets/images/characters/${player.avatar}`;
return (
<div
key={player.id}
onClick={() => isLeader && gameState.phase === GamePhase.TEAM_BUILDING && toggleTeamSelection(player.id)}
className={`
relative flex flex-col items-center cursor-pointer transition-all duration-300
${isSelected ? 'scale-110' : 'scale-100 opacity-80 hover:opacity-100'}
`}
>
{/* Avatar */}
<div className={`
w-16 h-16 rounded-full border-2 overflow-hidden relative shadow-lg bg-black
${isSelected ? 'border-yellow-400 ring-4 ring-yellow-400/30' : 'border-gray-400'}
${player.isLeader ? 'ring-2 ring-white' : ''}
`}>
<Image
src={avatarSrc}
alt={player.name}
fill
className="object-cover"
/>
{/* Icono de Líder */}
{player.isLeader && (
<div className="absolute bottom-0 right-0 bg-yellow-500 rounded-full p-1 w-6 h-6 flex items-center justify-center text-xs text-black font-bold border border-white z-10">
L
</div>
)}
</div>
{/* Nombre */}
<span className={`mt-2 text-xs font-mono px-2 py-0.5 rounded ${isMe ? 'bg-blue-600 text-white' : 'bg-black/50 text-gray-300'}`}>
{player.name}
</span>
);
})}
</div>
</div>
{/* HISTÓRICO DE MISIONES (Esquina superior derecha) */}
{gameState.missionHistory.length > 0 && (
<div className="absolute top-4 right-4 bg-black/80 p-3 rounded-lg border border-white/20 backdrop-blur-sm">
<div className="text-[10px] text-gray-400 uppercase mb-2 text-center font-bold tracking-wider">Historial</div>
<div className="flex gap-2">
{gameState.missionHistory.map((mission, idx) => (
<div
key={idx}
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold border-2 ${
mission.isSuccess
? 'bg-blue-600 border-blue-400 text-white'
: 'bg-red-600 border-red-400 text-white'
}`}
title={`Misión ${mission.round}: ${mission.isSuccess ? 'Éxito' : 'Fracaso'} (${mission.successes}✓ ${mission.fails}✗)`}
>
{mission.round}
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
// Subcomponente para el Timer de Votación
function VotingTimer({ onTimeout }: { onTimeout: () => void }) {
const [timeLeft, setTimeLeft] = useState(10);
useEffect(() => {
if (timeLeft <= 0) {
onTimeout();
return;
}
const interval = setInterval(() => setTimeLeft(t => t - 1), 1000);
return () => clearInterval(interval);
}, [timeLeft, onTimeout]);
return (
<div className="absolute top-4 right-4 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">
{timeLeft}
</div>
);
}
// Componente para revelar cartas una a una
function MissionReveal({ votes, onComplete }: { votes: boolean[], onComplete: () => void }) {
const [revealedCount, setRevealedCount] = useState(0);
useEffect(() => {
if (revealedCount < votes.length) {
const timer = setTimeout(() => {
setRevealedCount(c => c + 1);
}, 2000); // 2 segundos entre carta y carta
return () => clearTimeout(timer);
} else if (revealedCount === votes.length && votes.length > 0) {
// Todas reveladas, esperar 2s más y avanzar
const timer = setTimeout(() => {
onComplete();
}, 2000);
return () => clearTimeout(timer);
}
}, [revealedCount, votes.length, onComplete]);
return (
<motion.div
key="mission-reveal"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center gap-8"
>
<h2 className="text-3xl font-bold text-white uppercase tracking-widest">
Revelando Votos...
</h2>
<div className="flex gap-4 flex-wrap justify-center">
{votes.slice(0, revealedCount).map((vote, idx) => (
<motion.div
key={idx}
initial={{ scale: 0, rotateY: 180 }}
animate={{ scale: 1, rotateY: 0 }}
transition={{ type: "spring", stiffness: 200 }}
className="relative w-32 h-48"
>
<Image
src={vote ? "/assets/images/tokens/mission_success.png" : "/assets/images/tokens/mission_fail.png"}
alt={vote ? "Éxito" : "Sabotaje"}
fill
className="object-contain drop-shadow-2xl"
/>
</motion.div>
))}
</div>
<div className="text-white text-lg font-mono">
{revealedCount} / {votes.length} cartas reveladas
</div>
</motion.div>
);
}
// Componente para mostrar el resultado de la misión
function MissionResult({ gameState, onContinue }: { gameState: any, onContinue: () => void }) {
const currentMission = gameState.missionHistory[gameState.missionHistory.length - 1];
const isHost = gameState.hostId === gameState.players[0]?.id; // Simplificado
useEffect(() => {
// Auto-avanzar después de 5 segundos
const timer = setTimeout(() => {
onContinue();
}, 5000);
return () => clearTimeout(timer);
}, [onContinue]);
if (!currentMission) return null;
const { isSuccess, successes, fails, team, round } = currentMission;
return (
<motion.div
key="mission-result"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
className="flex flex-col items-center gap-6 p-8 bg-black/90 rounded-xl border-4 max-w-2xl mx-auto"
style={{ borderColor: isSuccess ? '#3b82f6' : '#ef4444' }}
>
{/* Título */}
<div className="text-center">
<h2 className="text-5xl font-bold mb-2 uppercase tracking-widest" style={{ color: isSuccess ? '#3b82f6' : '#ef4444' }}>
{isSuccess ? '✓ MISIÓN EXITOSA' : '✗ MISIÓN FALLIDA'}
</h2>
<p className="text-gray-400 text-lg">Misión #{round}</p>
</div>
{/* Estadísticas */}
<div className="flex gap-8 text-center">
<div className="bg-blue-900/30 p-4 rounded-lg border border-blue-500/50">
<div className="text-4xl font-bold text-blue-400">{successes}</div>
<div className="text-sm text-gray-300 uppercase">Éxitos</div>
</div>
<div className="bg-red-900/30 p-4 rounded-lg border border-red-500/50">
<div className="text-4xl font-bold text-red-400">{fails}</div>
<div className="text-sm text-gray-300 uppercase">Sabotajes</div>
</div>
</div>
{/* Equipo */}
<div className="w-full">
<h3 className="text-xl text-white mb-3 text-center uppercase tracking-wider">Equipo de Misión:</h3>
<div className="flex gap-3 justify-center flex-wrap">
{team.map((playerId: string) => {
const player = gameState.players.find((p: any) => p.id === playerId);
return (
<div key={playerId} className="bg-white/10 px-4 py-2 rounded-full border border-white/20 text-white text-sm font-mono">
{player?.name || 'Desconocido'}
</div>
);
})}
</div>
</div>
{/* Mensaje */}
<div className="text-center text-gray-300 text-sm animate-pulse">
Continuando en breve...
</div>
</motion.div>
);
}

View File

@@ -1,544 +0,0 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image';
import { GameState, GamePhase, Player, GAME_CONFIG } from '../../../shared/types';
interface GameBoardProps {
gameState: GameState;
currentPlayerId: string;
actions: any;
}
export default function GameBoard({ gameState, currentPlayerId, actions }: GameBoardProps) {
const [selectedTeam, setSelectedTeam] = useState<string[]>([]);
// Hooks para FASE REVEAL ROLE
const [revealCard, setRevealCard] = useState(false);
// Timer para avanzar automáticamente en REVEAL_ROLE
useEffect(() => {
if (gameState.phase === 'reveal_role' as any) {
const timer = setTimeout(() => {
actions.finishReveal();
}, 10000);
return () => clearTimeout(timer);
}
}, [gameState.phase, actions]);
const currentPlayer = gameState.players.find(p => p.id === currentPlayerId);
const isLeader = currentPlayer?.isLeader;
const config = GAME_CONFIG[gameState.players.length as keyof typeof GAME_CONFIG];
const currentQuestSize = config?.quests[gameState.currentRound - 1];
// Manejar selección de equipo
const toggleTeamSelection = (playerId: string) => {
if (selectedTeam.includes(playerId)) {
setSelectedTeam(selectedTeam.filter(id => id !== playerId));
} else {
if (selectedTeam.length < currentQuestSize) {
setSelectedTeam([...selectedTeam, playerId]);
}
}
};
// Coordenadas porcentuales de los hexágonos de misión en el mapa
const missionCoords = [
{ left: '12%', top: '55%' }, // Misión 1
{ left: '28%', top: '15%' }, // Misión 2
{ left: '52%', top: '25%' }, // Misión 3
{ left: '42%', top: '70%' }, // Misión 4
{ left: '82%', top: '40%' }, // Misión 5
];
// --- UI/Efectos para FASES TEMPRANAS ---
const isHost = gameState.hostId === currentPlayerId;
// FASE INTRO
if (gameState.phase === 'intro' as any) {
return (
<div className="relative w-full h-screen flex flex-col items-center justify-center bg-black overflow-hidden text-white">
<div className="absolute inset-0 z-0">
<Image src="/assets/images/ui/bg_intro.png" alt="Battlefield" fill className="object-cover" />
<div className="absolute inset-0 bg-black/40" />
</div>
<h1 className="z-10 text-5xl font-bold uppercase tracking-[0.3em] mb-8 text-yellow-500 drop-shadow-lg text-center">
Guerra Total
</h1>
{/* Audio Auto-Play */}
<audio
src="/assets/audio/Intro.ogg"
autoPlay
onEnded={() => isHost && actions.finishIntro()}
/>
{isHost && (
<button
onClick={() => actions.finishIntro()}
className="z-10 bg-white/20 hover:bg-white/40 border border-white px-6 py-2 rounded text-sm uppercase tracking-widest backdrop-blur-sm transition-all"
>
Omitir Introducción
</button>
)}
</div>
);
}
// FASE REVEAL ROLE NO HOOKS HERE
if (gameState.phase === 'reveal_role' as any) {
// Determinar imagen basada en el rol
// Mapeo básico:
// Merlin -> good_merlin.png
// Percival -> good_percival.png
// Servant -> good_soldier_X.png (random)
// Assassin -> evil_assassin.png
// Morgana -> evil_morgana.png
// Mordred -> evil_mordred.png
// Oberon -> evil_oberon.png
// Minion -> evil_minion_X.png
let roleImage = '/assets/images/characters/good_soldier_1.png'; // Default
const role = currentPlayer?.role;
if (role === 'merlin') roleImage = '/assets/images/characters/good_merlin.png';
else if (role === 'assassin') roleImage = '/assets/images/characters/evil_assassin.png';
else if (role === 'percival') roleImage = '/assets/images/characters/good_percival.png';
else if (role === 'morgana') roleImage = '/assets/images/characters/evil_morgana.png';
else if (role === 'mordred') roleImage = '/assets/images/characters/evil_mordred.png';
else if (role === 'oberon') roleImage = '/assets/images/characters/evil_oberon.png';
else if (role === 'loyal_servant') {
// Random soldier 1-5
const idx = (currentPlayerId.charCodeAt(0) % 5) + 1;
roleImage = `/assets/images/characters/good_soldier_${idx}.png`;
}
else if (role === 'minion') {
// Random minion 1-3
const idx = (currentPlayerId.charCodeAt(0) % 3) + 1;
roleImage = `/assets/images/characters/evil_minion_${idx}.png`;
}
return (
<div className="relative w-full h-screen flex flex-col items-center justify-center bg-black overflow-hidden text-white font-mono">
{/* FONDO (Mismo que Roll Call) */}
<div className="absolute inset-0 z-0">
<Image src="/assets/images/ui/bg_roll_call.png" alt="Resistance HQ" fill className="object-cover" />
<div className="absolute inset-0 bg-black/70" />
</div>
<div className="z-10 flex flex-col items-center gap-8">
<h2 className="text-2xl uppercase tracking-[0.2em] text-gray-300">
Tu Identidad Secreta
</h2>
<p className="text-sm text-gray-400 mb-4 animate-pulse">
Desliza hacia arriba para revelar
</p>
<div className="relative w-64 h-96 perspective-1000">
{/* Carta Revelada (Fondo) */}
<div className="absolute inset-0 w-full h-full rounded-xl overflow-hidden shadow-2xl border-4 border-yellow-600 bg-gray-900 flex items-center justify-center">
<Image
src={roleImage}
alt="Role"
fill
className="object-cover"
/>
<div className="absolute bottom-0 w-full bg-black/80 text-center py-2 font-bold text-yellow-500 uppercase">
{role?.replace('_', ' ')}
</div>
</div>
{/* Reverso de Carta (Draggable) */}
<motion.div
drag="y"
dragConstraints={{ top: -300, bottom: 0 }}
dragElastic={0.2}
onDragEnd={(e, info) => {
// Reducir umbral a -50 para facilitar
if (info.offset.y < -50) {
setRevealCard(true);
}
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98, cursor: 'grabbing' }}
animate={revealCard ? { y: -1000, opacity: 0 } : { y: 0, opacity: 1 }}
className="absolute inset-0 w-full h-full rounded-xl overflow-hidden shadow-2xl z-20 cursor-grab active:cursor-grabbing hover:ring-2 hover:ring-white/50 transition-all"
>
<Image
src="/assets/images/characters/card_back.png"
alt="Card Back"
fill
className="object-cover pointer-events-none" // Importante: pointer-events-none en la imagen para que no capture el drag
/>
</motion.div>
</div>
</div>
</div>
);
}
// FASE ROLL CALL
if (gameState.phase === 'roll_call' as any) {
return (
<div className="relative w-full h-screen flex flex-col items-center justify-center bg-black overflow-hidden text-white font-mono">
<div className="absolute inset-0 z-0">
<Image src="/assets/images/ui/bg_roll_call.png" alt="Resistance HQ" fill className="object-cover" />
<div className="absolute inset-0 bg-black/70" />
</div>
<div className="z-10 w-full max-w-5xl px-4">
<h2 className="text-3xl text-center mb-12 uppercase tracking-[0.2em] text-gray-300 border-b border-gray-600 pb-4">
Pasando Lista...
</h2>
{isHost && (
<audio
src="/assets/audio/Rondas.ogg"
autoPlay
onEnded={() => actions.finishRollCall()} // Host avanza cuando acaba audio
/>
)}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
{gameState.players.map((p, i) => {
// Asignar avatar determinista basado en charCode
const avatarIdx = (p.name.length % 3) + 1;
return (
<motion.div
key={p.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.3 }} // Aparecen uno a uno
className="flex flex-col items-center gap-3"
>
<div className="w-32 h-32 rounded-full border-4 border-gray-400 overflow-hidden relative shadow-2xl bg-black">
<Image
src={`/assets/images/characters/avatar_${avatarIdx}.png`}
alt="Avatar"
fill
className="object-cover grayscale contrast-125"
/>
</div>
<div className="bg-black/80 px-4 py-1 rounded border border-white/20 text-xl font-bold text-yellow-500 uppercase">
{p.name}
</div>
</motion.div>
);
})}
</div>
</div>
</div>
);
}
return (
<div className="relative w-full h-screen flex flex-col items-center overflow-hidden">
<div className="absolute inset-0 z-0 opacity-40">
<Image src="/assets/images/ui/bg_game.png" alt="Game Background" fill className="object-cover" />
<div className="absolute inset-0 bg-black/60" />
</div>
<div className="relative z-10 w-full flex flex-col items-center">
{/* --- MAPA TÁCTICO (TABLERO) --- */}
<div className="relative w-full max-w-5xl aspect-video mt-4 shadow-2xl border-4 border-gray-800 rounded-lg overflow-hidden bg-[#2a2a2a]">
<Image
src="/assets/images/ui/board_map.jpg"
alt="Tactical Map"
fill
className="object-contain"
/>
{/* TOKENS SOBRE EL MAPA */}
{missionCoords.map((coord, idx) => {
const result = gameState.questResults[idx];
const isCurrent = gameState.currentRound === idx + 1;
return (
<div
key={idx}
className="absolute w-[12%] aspect-square flex items-center justify-center transform -translate-x-1/2 -translate-y-1/2"
style={{ left: coord.left, top: coord.top }}
>
{/* Marcador de Ronda Actual */}
{isCurrent && (
<motion.div
layoutId="round-marker"
className="absolute inset-0 z-10"
initial={{ scale: 1.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<Image
src="/assets/images/tokens/marker_round.png"
alt="Current Round"
fill
className="object-contain drop-shadow-lg"
/>
</motion.div>
)}
{/* Resultado de Misión (Éxito/Fracaso) */}
{result === true && (
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
className="absolute inset-0 z-20"
>
<Image src="/assets/images/tokens/marker_score_blue.png" alt="Success" fill className="object-contain drop-shadow-lg" />
</motion.div>
)}
{result === false && (
<motion.div
initial={{ scale: 0 }} animate={{ scale: 1 }}
className="absolute inset-0 z-20"
>
<Image src="/assets/images/tokens/marker_score_red.png" alt="Fail" fill className="object-contain drop-shadow-lg" />
</motion.div>
)}
</div>
);
})}
{/* TRACK DE VOTOS FALLIDOS (Pequeño indicador en la esquina inferior izquierda del mapa) */}
<div className="absolute bottom-[5%] left-[2%] bg-black/60 p-2 rounded border border-white/20">
<div className="text-[10px] text-gray-300 uppercase mb-1 text-center">Votos Rechazados</div>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<div key={i} className={`w-3 h-3 rounded-full border border-gray-500 ${i < gameState.failedVotesCount ? 'bg-red-500' : 'bg-transparent'}`} />
))}
</div>
</div>
</div>
{/* --- ÁREA DE JUEGO (CARTAS Y ACCIONES) --- */}
<div className="flex-1 w-full max-w-6xl relative mt-4 px-4">
<AnimatePresence mode="wait">
{/* FASE: VOTACIÓN DE LÍDER */}
{gameState.phase === 'vote_leader' as any && (
<motion.div
key="vote-leader"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6"
>
<div className="bg-black/80 p-4 rounded text-white text-center border border-yellow-500/50 relative">
<h3 className="text-xl font-mono mb-2 text-yellow-500 uppercase tracking-widest">
Confirmar Líder
</h3>
<div className="text-2xl font-bold mb-2">
¿Aceptas a <span className="text-yellow-400">{gameState.players.find(p => p.id === gameState.currentLeaderId)?.name}</span> como Líder?
</div>
{/* Timer */}
{!gameState.leaderVotes?.[currentPlayerId] && (
<VotingTimer onTimeout={() => actions.voteLeader(null)} />
)}
</div>
{gameState.leaderVotes?.[currentPlayerId] === undefined ? (
<div className="flex gap-8">
<button onClick={() => actions.voteLeader(true)} className="group">
<div className="w-40 h-60 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-green-500 transition-all transform group-hover:-translate-y-4 relative overflow-hidden">
<Image src="/assets/images/tokens/accept_leader.png" alt="Accept Leader" fill className="object-contain" />
</div>
<span className="block text-center text-white mt-2 font-bold bg-green-600 px-2 rounded uppercase tracking-widest">ACEPTAR</span>
</button>
<button onClick={() => actions.voteLeader(false)} className="group">
<div className="w-40 h-60 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-red-500 transition-all transform group-hover:-translate-y-4 relative overflow-hidden">
<Image src="/assets/images/tokens/deny_leader.png" alt="Deny Leader" fill className="object-contain" />
</div>
<span className="block text-center text-white mt-2 font-bold bg-red-600 px-2 rounded uppercase tracking-widest">RECHAZAR</span>
</button>
</div>
) : (
<div className="text-white text-xl font-mono animate-pulse bg-black/50 px-6 py-3 rounded-full border border-white/20">
VOTO REGISTRADO. ESPERANDO AL RESTO...
</div>
)}
</motion.div>
)}
{/* FASE: CONSTRUCCIÓN DE EQUIPO */}
{gameState.phase === GamePhase.TEAM_BUILDING && (
<motion.div
key="team-building"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="bg-paper-bg text-black p-6 rounded shadow-2xl rotate-1 max-w-md w-full text-center"
>
<h2 className="text-2xl font-bold font-mono mb-2 uppercase text-resistance-blue">
{isLeader ? 'TU TURNO: ELIGE EQUIPO' : `ESPERANDO AL LÍDER...`}
</h2>
<p className="mb-4 font-serif italic text-gray-700">
Se necesitan <span className="font-bold text-red-700">{currentQuestSize} agentes</span> para esta misión.
</p>
{isLeader && (
<button
onClick={() => actions.proposeTeam(selectedTeam)}
disabled={selectedTeam.length !== currentQuestSize}
className="w-full bg-resistance-blue text-white font-bold py-3 px-4 rounded hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-widest"
>
Proponer Equipo
</button>
)}
</motion.div>
)}
{/* FASE: VOTACIÓN DE EQUIPO */}
{gameState.phase === GamePhase.VOTING_TEAM && (
<motion.div
key="voting"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center gap-6"
>
<div className="bg-black/80 p-4 rounded text-white text-center border border-white/20">
<h3 className="text-xl font-mono mb-2 text-yellow-500">PROPUESTA DE MISIÓN</h3>
<div className="flex gap-2 justify-center">
{gameState.proposedTeam.map(id => {
const p = gameState.players.find(pl => pl.id === id);
return (
<div key={id} className="bg-white/10 px-3 py-1 rounded text-sm">
{p?.name}
</div>
);
})}
</div>
</div>
{!currentPlayer?.hasVoted ? (
<div className="flex gap-8">
<button onClick={() => actions.voteTeam(true)} className="group">
<div className="w-32 h-48 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-green-500 transition-all transform group-hover:-translate-y-4">
<Image src="/assets/images/tokens/vote_approve.png" alt="Approve" width={100} height={100} />
</div>
<span className="block text-center text-white mt-2 font-bold bg-green-600 px-2 rounded">APROBAR</span>
</button>
<button onClick={() => actions.voteTeam(false)} className="group">
<div className="w-32 h-48 bg-white rounded-lg shadow-xl flex items-center justify-center border-4 border-transparent group-hover:border-red-500 transition-all transform group-hover:-translate-y-4">
<Image src="/assets/images/tokens/vote_reject.png" alt="Reject" width={100} height={100} />
</div>
<span className="block text-center text-white mt-2 font-bold bg-red-600 px-2 rounded">RECHAZAR</span>
</button>
</div>
) : (
<div className="text-white text-xl font-mono animate-pulse">
VOTO REGISTRADO. ESPERANDO AL RESTO...
</div>
)}
</motion.div>
)}
{/* FASE: MISIÓN */}
{gameState.phase === GamePhase.MISSION && (
<motion.div key="mission" className="text-center">
{gameState.proposedTeam.includes(currentPlayerId) ? (
<div className="flex flex-col items-center gap-6">
<h2 className="text-3xl font-bold text-white mb-4 drop-shadow-lg">¡ESTÁS EN LA MISIÓN!</h2>
<div className="flex gap-8">
<button onClick={() => actions.voteMission(true)} className="group">
<div className="w-40 h-60 bg-blue-900 rounded-lg shadow-2xl border-2 border-blue-400 flex flex-col items-center justify-center p-4 transform transition-transform hover:scale-105">
<Image src="/assets/images/tokens/mission_success.png" alt="Success" width={120} height={120} />
<span className="mt-4 text-blue-200 font-bold tracking-widest">ÉXITO</span>
</div>
</button>
{/* Solo los malos pueden sabotear */}
{currentPlayer?.faction === 'spies' && (
<button onClick={() => actions.voteMission(false)} className="group">
<div className="w-40 h-60 bg-red-900 rounded-lg shadow-2xl border-2 border-red-400 flex flex-col items-center justify-center p-4 transform transition-transform hover:scale-105">
<Image src="/assets/images/tokens/mission_fail.png" alt="Fail" width={120} height={120} />
<span className="mt-4 text-red-200 font-bold tracking-widest">SABOTAJE</span>
</div>
</button>
)}
</div>
</div>
) : (
<div className="text-white text-2xl font-mono bg-black/50 p-6 rounded">
La misión está en curso...<br />
<span className="text-sm text-gray-400">Rezando por el éxito.</span>
</div>
)}
</motion.div>
)}
{/* FASE: REVELACIÓN DE CARTAS */}
{gameState.phase === 'mission_reveal' as any && (
<MissionReveal
votes={gameState.revealedVotes || []}
onComplete={() => isHost && actions.finishMissionReveal()}
/>
)}
{/* FASE: RESULTADO DE MISIÓN */}
{gameState.phase === 'mission_result' as any && (
<MissionResult
gameState={gameState}
onContinue={() => isHost && actions.finishMissionResult()}
/>
)}
</AnimatePresence>
</div>
{/* JUGADORES (TIENDA DE CAMPAÑA) */}
<div className="z-10 w-full overflow-x-auto pb-4">
<div className="flex justify-center gap-4 min-w-max px-4">
{gameState.players.map((player) => {
const isSelected = selectedTeam.includes(player.id);
const isMe = player.id === currentPlayerId;
// Avatar logic
const avatarSrc = `/assets/images/characters/${player.avatar}`;
return (
<div
key={player.id}
onClick={() => isLeader && gameState.phase === GamePhase.TEAM_BUILDING && toggleTeamSelection(player.id)}
className={`
relative flex flex-col items-center cursor-pointer transition-all duration-300
${isSelected ? 'scale-110' : 'scale-100 opacity-80 hover:opacity-100'}
`}
>
{/* Avatar */}
<div className={`
w-16 h-16 rounded-full border-2 overflow-hidden relative shadow-lg bg-black
${isSelected ? 'border-yellow-400 ring-4 ring-yellow-400/30' : 'border-gray-400'}
${player.isLeader ? 'ring-2 ring-white' : ''}
`}>
<Image
src={avatarSrc}
alt={player.name}
fill
className="object-cover"
/>
{/* Icono de Líder */}
{player.isLeader && (
<div className="absolute bottom-0 right-0 bg-yellow-500 rounded-full p-1 w-6 h-6 flex items-center justify-center text-xs text-black font-bold border border-white z-10">
L
</div>
)}
</div>
{/* Nombre */}
<span className={`mt-2 text-xs font-mono px-2 py-0.5 rounded ${isMe ? 'bg-blue-600 text-white' : 'bg-black/50 text-gray-300'}`}>
{player.name}
</span>
);
})}
</div>
</div>
{/* HISTÓRICO DE MISIONES (Esquina superior derecha) */}
{gameState.missionHistory.length > 0 && (
<div className="absolute top-4 right-4 bg-black/80 p-3 rounded-lg border border-white/20 backdrop-blur-sm">
<div className="text-[10px] text-gray-400 uppercase mb-2 text-center font-bold tracking-wider">Historial</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

@@ -23,12 +23,12 @@ export default function MissionResult({ gameState, onContinue, isHost }: Mission
return (
<motion.div
className="fixed inset-0 flex flex-col items-center justify-center bg-transparent z-50"
className="fixed inset-0 flex flex-col items-center justify-center bg-transparent z-50 pt-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<motion.h2
className={`text-6xl md:text-7xl font-bold mb-8 ${isSuccess ? 'text-blue-500' : 'text-red-500'}`}
className={`text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold mb-8 whitespace-nowrap px-4 ${isSuccess ? 'text-blue-500' : 'text-red-500'}`}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, delay: 0.2 }}
@@ -62,7 +62,7 @@ export default function MissionResult({ gameState, onContinue, isHost }: Mission
{isHost ? (
<motion.button
onClick={onContinue}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-bold py-4 px-8 rounded-lg text-xl shadow-lg transform transition-all hover:scale-105"
className="bg-gradient-to-r from-yellow-600 to-yellow-700 hover:from-yellow-500 hover:to-yellow-600 text-white font-bold py-4 px-8 rounded-lg text-lg shadow-lg transform transition-all hover:scale-105"
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 1.5 }}

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(() => {
@@ -23,15 +33,15 @@ export default function MissionReveal({ votes, onFinished }: MissionRevealProps)
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<h2 className="text-5xl font-bold text-white mb-12 uppercase tracking-widest drop-shadow-lg">
Resultado de Misión
<h2 className="text-3xl font-bold text-white mb-12 uppercase tracking-widest drop-shadow-lg text-center">
Resultado de la misión
</h2>
<div className="flex gap-8 justify-center mb-12 flex-wrap max-w-[90vw]">
{votes.map((vote, idx) => (
<div className="flex gap-4 justify-center mb-12 flex-wrap max-w-[90vw]">
{shuffledVotes.map((vote, idx) => (
<motion.div
key={idx}
className="w-48 h-72 rounded-xl flex items-center justify-center shadow-2xl relative overflow-hidden"
className="w-32 h-48 rounded-xl flex items-center justify-center shadow-2xl relative overflow-hidden"
initial={{ scale: 0, rotateY: 180 }}
animate={{ scale: 1, rotateY: 0 }}
transition={{
@@ -52,10 +62,10 @@ export default function MissionReveal({ votes, onFinished }: MissionRevealProps)
</div>
<motion.div
className="text-white text-xl font-mono mt-8"
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

@@ -43,7 +43,7 @@ export default function VictoryScreen({ gameState, isHost, onRestart, onFinalize
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, delay: 0.2 }}
>
<h1 className={`text-7xl md:text-8xl font-bold mb-4 drop-shadow-[0_4px_8px_rgba(0,0,0,0.9)] ${isNazisWin ? 'text-red-600' : 'text-blue-500'}`}>
<h1 className={`text-[4rem] md:text-[4.5rem] font-bold mb-4 drop-shadow-[0_4px_8px_rgba(0,0,0,0.9)] ${isNazisWin ? 'text-red-600' : 'text-blue-500'}`}>
{isNazisWin ? '¡VICTORIA NAZI!' : '¡VICTORIA ALIADA!'}
</h1>
<p className="text-3xl text-white drop-shadow-[0_4px_8px_rgba(0,0,0,0.9)] font-bold">

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

94
deploy copy.sh Executable file
View File

@@ -0,0 +1,94 @@
#!/bin/bash
# Script de deployment para Francia Ocupada
# Este script se ejecuta en el HOST, no en el runner
set -e # Salir si hay algún error
echo "🚀 Iniciando deployment de Francia Ocupada..."
echo "================================================"
# Directorio del proyecto
PROJECT_DIR="/home/marti/docker/FranciaOcupada"
cd "$PROJECT_DIR"
echo "📂 Directorio de trabajo: $(pwd)"
echo ""
# PASO 1: Actualizar código desde Git
echo "📥 PASO 1: Actualizando código desde Git..."
git fetch origin
git reset --hard origin/main
echo "✅ Código actualizado"
echo ""
# PASO 2: Detener contenedores anteriores
echo "🛑 PASO 2: Deteniendo contenedores anteriores..."
docker compose -f docker-compose_prod.yml down || true
docker container prune -f || true
echo "✅ Contenedores anteriores detenidos"
echo ""
# PASO 3: Limpiar imágenes antiguas
echo "🧹 PASO 3: Limpiando imágenes antiguas..."
docker image prune -f || true
echo "✅ Limpieza completada"
echo ""
# PASO 4: Construir imágenes Docker
echo "🔨 PASO 4: Construyendo imágenes Docker..."
docker compose -f docker-compose_prod.yml build --no-cache
# Etiquetar con timestamp para trazabilidad
TAG_VERSION=$(date +%Y%m%d_%H%M%S)
docker tag resistencia-client:latest resistencia-client:${TAG_VERSION} || true
docker tag resistencia-server:latest resistencia-server:${TAG_VERSION} || true
echo "✅ Imágenes construidas:"
echo " - resistencia-client:latest (${TAG_VERSION})"
echo " - resistencia-server:latest (${TAG_VERSION})"
echo ""
# PASO 5: Desplegar contenedores
echo "📦 PASO 5: Desplegando aplicación..."
docker compose -f docker-compose_prod.yml up -d
echo "✅ Aplicación desplegada exitosamente"
echo ""
# PASO 6: Verificar deployment
echo "✅ PASO 6: Verificando deployment..."
sleep 10
echo "📊 Estado de los contenedores:"
docker compose -f docker-compose_prod.yml ps
echo ""
# Verificar que los contenedores están corriendo
RUNNING_CONTAINERS=$(docker compose -f docker-compose_prod.yml ps -q | wc -l)
if [ "$RUNNING_CONTAINERS" -eq 0 ]; then
echo "❌ ERROR: No hay contenedores corriendo"
docker compose -f docker-compose_prod.yml logs
exit 1
fi
echo "✅ Verificación completada - $RUNNING_CONTAINERS contenedores corriendo"
echo ""
# PASO 7: Mostrar logs recientes
echo "📋 PASO 7: Logs recientes..."
echo ""
echo "--- Logs del Cliente (últimas 20 líneas) ---"
docker compose -f docker-compose_prod.yml logs --tail=20 client || true
echo ""
echo "--- Logs del Servidor (últimas 20 líneas) ---"
docker compose -f docker-compose_prod.yml logs --tail=20 server || true
echo ""
echo "================================================"
echo "🎉 Deployment completado exitosamente!"
echo ""
echo "🌐 Aplicación disponible en:"
echo " - Frontend: https://franciaocupada.martivich.es"
echo " - API: https://api.franciaocupada.martivich.es"
echo "================================================"

94
deploy.sh Executable file
View File

@@ -0,0 +1,94 @@
#!/bin/bash
# Script de deployment para Francia Ocupada
# Este script se ejecuta en el HOST, no en el runner
set -e # Salir si hay algún error
echo "🚀 Iniciando deployment de Francia Ocupada..."
echo "================================================"
# Directorio del proyecto
PROJECT_DIR="/home/marti/Documentos/Gitea/resistencia"
cd "$PROJECT_DIR"
echo "📂 Directorio de trabajo: $(pwd)"
echo ""
# PASO 1: Actualizar código desde Git
echo "📥 PASO 1: Actualizando código desde Git..."
git fetch origin
git reset --hard origin/main
echo "✅ Código actualizado"
echo ""
# PASO 2: Detener contenedores anteriores
echo "🛑 PASO 2: Deteniendo contenedores anteriores..."
docker compose -f docker-compose_prod.yml down || true
docker container prune -f || true
echo "✅ Contenedores anteriores detenidos"
echo ""
# PASO 3: Limpiar imágenes antiguas
echo "🧹 PASO 3: Limpiando imágenes antiguas..."
docker image prune -f || true
echo "✅ Limpieza completada"
echo ""
# PASO 4: Construir imágenes Docker
echo "🔨 PASO 4: Construyendo imágenes Docker..."
docker compose -f docker-compose_prod.yml build --no-cache
# Etiquetar con timestamp para trazabilidad
TAG_VERSION=$(date +%Y%m%d_%H%M%S)
docker tag resistencia-client:latest resistencia-client:${TAG_VERSION} || true
docker tag resistencia-server:latest resistencia-server:${TAG_VERSION} || true
echo "✅ Imágenes construidas:"
echo " - resistencia-client:latest (${TAG_VERSION})"
echo " - resistencia-server:latest (${TAG_VERSION})"
echo ""
# PASO 5: Desplegar contenedores
echo "📦 PASO 5: Desplegando aplicación..."
docker compose -f docker-compose_prod.yml up -d
echo "✅ Aplicación desplegada exitosamente"
echo ""
# PASO 6: Verificar deployment
echo "✅ PASO 6: Verificando deployment..."
sleep 10
echo "📊 Estado de los contenedores:"
docker compose -f docker-compose_prod.yml ps
echo ""
# Verificar que los contenedores están corriendo
RUNNING_CONTAINERS=$(docker compose -f docker-compose_prod.yml ps -q | wc -l)
if [ "$RUNNING_CONTAINERS" -eq 0 ]; then
echo "❌ ERROR: No hay contenedores corriendo"
docker compose -f docker-compose_prod.yml logs
exit 1
fi
echo "✅ Verificación completada - $RUNNING_CONTAINERS contenedores corriendo"
echo ""
# PASO 7: Mostrar logs recientes
echo "📋 PASO 7: Logs recientes..."
echo ""
echo "--- Logs del Cliente (últimas 20 líneas) ---"
docker compose -f docker-compose_prod.yml logs --tail=20 client || true
echo ""
echo "--- Logs del Servidor (últimas 20 líneas) ---"
docker compose -f docker-compose_prod.yml logs --tail=20 server || true
echo ""
echo "================================================"
echo "🎉 Deployment completado exitosamente!"
echo ""
echo "🌐 Aplicación disponible en:"
echo " - Frontend: https://franciaocupada.martivich.es"
echo " - API: https://api.franciaocupada.martivich.es"
echo "================================================"

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:

131
monitor-deploy.sh Executable file
View File

@@ -0,0 +1,131 @@
#!/bin/bash
# Script de monitoreo para el despliegue de Francia Ocupada
# Uso: ./monitor-deploy.sh
echo "🔍 Monitor de Despliegue - Francia Ocupada"
echo "=========================================="
echo ""
# Colores para output
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Función para verificar estado de contenedores
check_containers() {
echo -e "${YELLOW}📦 Estado de Contenedores:${NC}"
docker compose -f docker-compose_prod.yml ps
echo ""
}
# Función para verificar logs
check_logs() {
echo -e "${YELLOW}📋 Logs Recientes (últimas 20 líneas):${NC}"
echo ""
echo "--- CLIENT ---"
docker compose -f docker-compose_prod.yml logs --tail=20 client 2>/dev/null || echo "Cliente no disponible"
echo ""
echo "--- SERVER ---"
docker compose -f docker-compose_prod.yml logs --tail=20 server 2>/dev/null || echo "Servidor no disponible"
echo ""
}
# Función para verificar conectividad
check_connectivity() {
echo -e "${YELLOW}🌐 Verificación de Conectividad:${NC}"
# Verificar puerto 3000 (cliente)
if nc -z localhost 3000 2>/dev/null; then
echo -e "${GREEN}✅ Puerto 3000 (Cliente): ABIERTO${NC}"
else
echo -e "${RED}❌ Puerto 3000 (Cliente): CERRADO${NC}"
fi
# Verificar puerto 4000 (servidor)
if nc -z localhost 4000 2>/dev/null; then
echo -e "${GREEN}✅ Puerto 4000 (Servidor): ABIERTO${NC}"
else
echo -e "${RED}❌ Puerto 4000 (Servidor): CERRADO${NC}"
fi
# Verificar puerto 5432 (base de datos)
if nc -z localhost 5432 2>/dev/null; then
echo -e "${GREEN}✅ Puerto 5432 (Base de Datos): ABIERTO${NC}"
else
echo -e "${RED}❌ Puerto 5432 (Base de Datos): CERRADO${NC}"
fi
echo ""
}
# Función para verificar uso de recursos
check_resources() {
echo -e "${YELLOW}💻 Uso de Recursos:${NC}"
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" \
$(docker compose -f docker-compose_prod.yml ps -q 2>/dev/null) 2>/dev/null || echo "No hay contenedores corriendo"
echo ""
}
# Menú principal
while true; do
echo ""
echo "Selecciona una opción:"
echo "1) Ver estado de contenedores"
echo "2) Ver logs recientes"
echo "3) Verificar conectividad"
echo "4) Ver uso de recursos"
echo "5) Monitoreo completo"
echo "6) Logs en tiempo real (Ctrl+C para salir)"
echo "7) Reiniciar servicios"
echo "8) Detener servicios"
echo "9) Salir"
echo ""
read -p "Opción: " option
case $option in
1)
check_containers
;;
2)
check_logs
;;
3)
check_connectivity
;;
4)
check_resources
;;
5)
check_containers
check_connectivity
check_resources
check_logs
;;
6)
echo "📡 Logs en tiempo real (Ctrl+C para detener)..."
docker compose -f docker-compose_prod.yml logs -f
;;
7)
echo "🔄 Reiniciando servicios..."
docker compose -f docker-compose_prod.yml restart
echo -e "${GREEN}✅ Servicios reiniciados${NC}"
;;
8)
read -p "¿Estás seguro de que quieres detener los servicios? (s/n): " confirm
if [ "$confirm" = "s" ]; then
echo "🛑 Deteniendo servicios..."
docker compose -f docker-compose_prod.yml down
echo -e "${GREEN}✅ Servicios detenidos${NC}"
fi
;;
9)
echo "👋 ¡Hasta luego!"
exit 0
;;
*)
echo -e "${RED}❌ Opción inválida${NC}"
;;
esac
done

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

@@ -29,6 +29,7 @@ export class Game {
this.state = {
roomId,
roomName,
phase: GamePhase.LOBBY,
players: [],
currentRound: 1,
@@ -42,7 +43,9 @@ export class Game {
missionHistory: [],
revealedVotes: [],
history: [],
hostId: hostId
hostId: hostId,
matchNumber: 1,
matchResults: []
};
}
@@ -368,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];
@@ -406,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

@@ -62,6 +62,7 @@ export interface MissionRecord {
export interface GameState {
roomId: string;
roomName: string;
phase: GamePhase;
players: Player[];
hostId: string;
@@ -88,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)

174
useful-commands.sh Executable file
View File

@@ -0,0 +1,174 @@
#!/bin/bash
# 🚀 Comandos Útiles para CI/CD - Francia Ocupada
# Este archivo contiene comandos útiles para gestionar el CI/CD
echo "==================================================="
echo "🎮 Francia Ocupada - Comandos Útiles CI/CD"
echo "==================================================="
echo ""
# Función para mostrar comandos con descripción
show_command() {
echo -e "\033[1;33m$1\033[0m"
echo -e "\033[0;36m$2\033[0m"
echo ""
}
echo "📦 GESTIÓN DE CONTENEDORES"
echo "---------------------------------------------------"
show_command "Ver estado de contenedores:" \
"docker compose -f docker-compose_prod.yml ps"
show_command "Iniciar servicios:" \
"docker compose -f docker-compose_prod.yml up -d"
show_command "Detener servicios:" \
"docker compose -f docker-compose_prod.yml down"
show_command "Reiniciar servicios:" \
"docker compose -f docker-compose_prod.yml restart"
show_command "Reconstruir y reiniciar:" \
"docker compose -f docker-compose_prod.yml up -d --build"
echo ""
echo "📋 LOGS Y DEBUGGING"
echo "---------------------------------------------------"
show_command "Ver todos los logs:" \
"docker compose -f docker-compose_prod.yml logs"
show_command "Logs en tiempo real:" \
"docker compose -f docker-compose_prod.yml logs -f"
show_command "Logs del cliente:" \
"docker compose -f docker-compose_prod.yml logs -f client"
show_command "Logs del servidor:" \
"docker compose -f docker-compose_prod.yml logs -f server"
show_command "Logs de la base de datos:" \
"docker compose -f docker-compose_prod.yml logs -f db"
show_command "Últimas 100 líneas de logs:" \
"docker compose -f docker-compose_prod.yml logs --tail=100"
echo ""
echo "🔍 INSPECCIÓN Y DEBUGGING"
echo "---------------------------------------------------"
show_command "Entrar al contenedor del cliente:" \
"docker exec -it resistencia-client sh"
show_command "Entrar al contenedor del servidor:" \
"docker exec -it resistencia-server sh"
show_command "Entrar a la base de datos:" \
"docker exec -it resistencia-db psql -U postgres -d resistencia"
show_command "Ver uso de recursos:" \
"docker stats"
show_command "Inspeccionar red:" \
"docker network inspect resistencia_resistencia-net"
echo ""
echo "🧹 LIMPIEZA"
echo "---------------------------------------------------"
show_command "Limpiar contenedores detenidos:" \
"docker container prune -f"
show_command "Limpiar imágenes sin usar:" \
"docker image prune -f"
show_command "Limpiar todo (CUIDADO):" \
"docker system prune -a -f"
show_command "Limpiar volúmenes (CUIDADO - BORRA DATOS):" \
"docker volume prune -f"
echo ""
echo "🔧 TROUBLESHOOTING"
echo "---------------------------------------------------"
show_command "Ver puertos en uso:" \
"sudo lsof -i :3000 :4000 :5432"
show_command "Verificar conectividad local:" \
"curl http://localhost:3000 && curl http://localhost:4000"
show_command "Ver configuración de docker-compose:" \
"docker compose -f docker-compose_prod.yml config"
show_command "Reconstruir sin caché:" \
"docker compose -f docker-compose_prod.yml build --no-cache"
echo ""
echo "🚀 GIT Y DESPLIEGUE"
echo "---------------------------------------------------"
show_command "Ver estado de Git:" \
"git status"
show_command "Ver último commit:" \
"git log -1"
show_command "Trigger CI/CD (commit vacío):" \
"git commit --allow-empty -m 'test: Trigger CI/CD' && git push origin main"
show_command "Ver commits recientes:" \
"git log --oneline -10"
show_command "Ver diferencias con origin:" \
"git diff origin/main"
echo ""
echo "🌐 VERIFICACIÓN DE PRODUCCIÓN"
echo "---------------------------------------------------"
show_command "Verificar frontend (local):" \
"curl -I http://localhost:3000"
show_command "Verificar API (local):" \
"curl -I http://localhost:4000"
show_command "Verificar frontend (producción):" \
"curl -I https://franciaocupada.martivich.es"
show_command "Verificar API (producción):" \
"curl -I https://api.franciaocupada.martivich.es"
echo ""
echo "📊 MONITOREO"
echo "---------------------------------------------------"
show_command "Script de monitoreo interactivo:" \
"./monitor-deploy.sh"
show_command "Ver acciones de Gitea (navegador):" \
"xdg-open http://gitea.local:3000/marti/FranciaOcupada/actions"
echo ""
echo "💾 BACKUP Y RESTAURACIÓN"
echo "---------------------------------------------------"
show_command "Backup de la base de datos:" \
"docker exec resistencia-db pg_dump -U postgres resistencia > backup_\$(date +%Y%m%d_%H%M%S).sql"
show_command "Restaurar base de datos:" \
"docker exec -i resistencia-db psql -U postgres resistencia < backup.sql"
show_command "Exportar volumen de datos:" \
"docker run --rm -v resistencia_postgres_data:/data -v \$(pwd):/backup alpine tar czf /backup/postgres_data_backup.tar.gz -C /data ."
echo ""
echo "🎯 COMANDOS RÁPIDOS"
echo "---------------------------------------------------"
show_command "Reinicio completo (desarrollo):" \
"docker compose down && docker compose up -d --build"
show_command "Reinicio completo (producción):" \
"docker compose -f docker-compose_prod.yml down && docker compose -f docker-compose_prod.yml up -d --build"
show_command "Ver todo el estado del sistema:" \
"docker compose -f docker-compose_prod.yml ps && docker compose -f docker-compose_prod.yml logs --tail=20"
echo ""
echo "==================================================="
echo "💡 Tip: Guarda este archivo como referencia"
echo " Ejecuta: cat useful-commands.sh | less"
echo "==================================================="