feat: Complete Admin Dashboard with game control and player management (v0.0.8-alpha)
## Major Features Added - **🎛️ Complete Admin Dashboard**: Real-time player monitoring with detailed stats - **👥 Player Management**: Individual and mass player kicking with proper notifications - **🎯 Global Round Control**: Advance/retreat rounds across all rooms simultaneously - **⏸️ Game Control**: Pause/resume games from admin interface - **🔔 Client Notifications**: Players receive alerts for kicks and round changes ## Technical Improvements - **🏗️ Official Colyseus API**: Replaced global variable hacks with `matchMaker.query()` and `matchMaker.remoteRoomCall()` - **📡 Proper Client Communication**: Implemented broadcast messages for `adminKicked`, `gamePaused`, `gameResumed`, `roundChanged` - **🎮 Enhanced GameRoom Methods**: Added `pauseGame()`, `resumeGame()`, `advanceRound()`, `previousRound()`, `_forceClientDisconnect()`, `_forceDisconnectAllClients()`, `getInspectData()` ## UI/UX Enhancements - **📊 Detailed Player Info**: Name, room, role, producer type, and current tokens (🦃☕🌽) - **🚫 Proper Kick Notifications**: Clients auto-redirect to home with clear messaging - **🎨 Improved Admin Interface**: Better organized controls for non-technical commentators - **📱 Responsive Design**: Works well on different screen sizes ## Bug Fixes - **🔧 Fixed Admin Service URLs**: Now correctly calls Colyseus server (port 2567) instead of admin server (port 3001) - **✅ Mass Kick Notifications**: All players receive proper notifications when expelled en masse - **🔄 Auto-redirect**: Kicked clients properly return to home screen ## Architecture - **🏗️ Clean API Design**: All admin endpoints use official Colyseus patterns - **🔒 Type Safety**: Maintained TypeScript sync between server and clients - **📦 Microservices Ready**: Separated concerns between game server and admin interface **Breaking Changes:** None - fully backward compatible **Migration:** No migration needed
This commit is contained in:
27
CHANGELOG.md
27
CHANGELOG.md
@@ -11,11 +11,36 @@ y este proyecto adhiere a [Semantic Versioning](https://semver.org/spec/v2.0.0.h
|
|||||||
- Ronda 2-5: Implementar reglas evolutivas
|
- Ronda 2-5: Implementar reglas evolutivas
|
||||||
- Sistema de Judge rotativo
|
- Sistema de Judge rotativo
|
||||||
- Shame tokens y penalizaciones
|
- Shame tokens y penalizaciones
|
||||||
- UI de administración completa
|
|
||||||
- Efectos de sonido
|
- Efectos de sonido
|
||||||
- PWA support
|
- PWA support
|
||||||
- Multi-idioma
|
- Multi-idioma
|
||||||
|
|
||||||
|
## [0.0.8-alpha] - 2025-07-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Admin Dashboard Completo**: UI de administración con información detallada de jugadores
|
||||||
|
- **Control de Jugadores**: Expulsar jugadores individuales y todos los jugadores
|
||||||
|
- **Control de Rondas Global**: Avanzar y retroceder rondas en todas las salas simultáneamente
|
||||||
|
- **Control de Juego**: Pausar y reanudar juegos desde el admin
|
||||||
|
- **Notificaciones a Clientes**: Los jugadores reciben notificaciones cuando son expulsados o cuando cambian las rondas
|
||||||
|
- **Información Detallada de Jugadores**: Nombre, sala, rol, tipo de productor y tokens actuales
|
||||||
|
- **Redirección Automática**: Los clientes expulsados vuelven automáticamente al home
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **API Oficial de Colyseus**: Refactorizado todos los endpoints admin para usar `matchMaker.query()` y `matchMaker.remoteRoomCall()`
|
||||||
|
- **Arquitectura sin Variables Globales**: Eliminado el hack de variables globales por implementación oficial
|
||||||
|
- **UI Admin Mejorada**: Información más clara y organizada para comentaristas no-técnicos
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Notificación de Expulsión**: Los clientes ahora reciben notificación correcta cuando son expulsados
|
||||||
|
- **URLs del Admin Service**: Corregido para llamar al servidor Colyseus (puerto 2567) en lugar del admin (puerto 3001)
|
||||||
|
- **Expulsión Masiva**: Todos los jugadores reciben notificación apropiada cuando se expulsan todos
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- **GameRoom Methods**: Implementado `pauseGame()`, `resumeGame()`, `advanceRound()`, `previousRound()`, `_forceClientDisconnect()`, `_forceDisconnectAllClients()`, `getInspectData()`
|
||||||
|
- **Client Notifications**: Manejo de mensajes `adminKicked`, `gamePaused`, `gameResumed`, `roundChanged`
|
||||||
|
- **Type Safety**: Mantenida sincronización de tipos TypeScript entre servidor y clientes
|
||||||
|
|
||||||
## [0.0.5-alpha] - 2025-01-04
|
## [0.0.5-alpha] - 2025-01-04
|
||||||
|
|
||||||
### Añadido
|
### Añadido
|
||||||
|
|||||||
74
CLAUDE.md
74
CLAUDE.md
@@ -72,16 +72,39 @@ nginx-proxy-manager # Proxy reverso y balanceador
|
|||||||
- `/server` → API Servidor Colyseus
|
- `/server` → API Servidor Colyseus
|
||||||
|
|
||||||
## UI de Administración
|
## UI de Administración
|
||||||
**Funcionalidades:**
|
**Arquitectura:**
|
||||||
- Estadísticas en tiempo real de partidas activas
|
- Servidor Express independiente (Puerto 3001)
|
||||||
- Leaderboard global
|
- Comunicación SSE con servidor Colyseus
|
||||||
- Monitor de estado de jugadores
|
- Actualización de estado cada 500ms (polling)
|
||||||
|
- Una interfaz principal (múltiples conexiones opcionales)
|
||||||
|
|
||||||
|
**Funcionalidades principales:**
|
||||||
|
- Dashboard con estadísticas en tiempo real:
|
||||||
|
- Cantidad de jugadores conectados
|
||||||
|
- Cantidad de partidas activas
|
||||||
|
- Ronda actual del juego global
|
||||||
|
- Estados: esperando jugadores, pausa, juego terminado
|
||||||
|
- Nombres de las rondas
|
||||||
|
- Panel de control:
|
||||||
|
- Expulsar jugadores individuales y todos (con notificaciones)
|
||||||
|
- Control de rondas global (avanzar/retroceder en todas las salas)
|
||||||
|
- Pausar/reanudar juego
|
||||||
|
- Cancelar partidas de grupos específicos
|
||||||
|
- Lista de jugadores detallada:
|
||||||
|
- Nombre, sala, rol del juego y tipo de productor
|
||||||
|
- Tokens actuales (🦃 pavos, ☕ café, 🌽 maíz)
|
||||||
|
- Estado de conexión en tiempo real
|
||||||
|
- Sistema de notificaciones a clientes:
|
||||||
|
- Expulsión con redirección automática
|
||||||
|
- Cambios de ronda globales
|
||||||
|
- Pausas y reanudaciones
|
||||||
- Panel de debugging para IT profesional
|
- Panel de debugging para IT profesional
|
||||||
- Transparencia total del estado del servidor
|
- Transparencia total del estado del servidor
|
||||||
|
|
||||||
**Usuarios objetivo:**
|
**Usuarios objetivo:**
|
||||||
- Admin no-técnico: Vista simple de estadísticas
|
- Admin no-técnico: Vista simple de estadísticas
|
||||||
- IT profesional: Vista detallada de debugging
|
- IT profesional: Vista detallada de debugging
|
||||||
|
- Comentaristas deportivos: Información clara para narración en vivo
|
||||||
|
|
||||||
## Comandos Importantes
|
## Comandos Importantes
|
||||||
|
|
||||||
@@ -175,6 +198,48 @@ client/src/types/
|
|||||||
2. **Tipos auxiliares**: Se copian manualmente cuando se añaden/modifican
|
2. **Tipos auxiliares**: Se copian manualmente cuando se añaden/modifican
|
||||||
3. **Consistencia**: Usar nombres idénticos entre server y client
|
3. **Consistencia**: Usar nombres idénticos entre server y client
|
||||||
|
|
||||||
|
## API Admin - Arquitectura Técnica
|
||||||
|
|
||||||
|
### Endpoints Implementados
|
||||||
|
**Servidor Colyseus (Puerto 2567):**
|
||||||
|
- `GET /api/admin/stats` - Estadísticas en tiempo real usando `matchMaker.query()`
|
||||||
|
- `POST /api/admin/kick-player` - Expulsar jugador específico con notificación
|
||||||
|
- `POST /api/admin/kick-all-players` - Expulsar todos los jugadores con notificaciones
|
||||||
|
- `POST /api/admin/pause-game` - Pausar todas las partidas activas
|
||||||
|
- `POST /api/admin/resume-game` - Reanudar partidas pausadas
|
||||||
|
- `POST /api/admin/advance-round` - Avanzar ronda globalmente
|
||||||
|
- `POST /api/admin/previous-round` - Retroceder ronda globalmente
|
||||||
|
- `POST /api/admin/cancel-game` - Cancelar partidas específicas o todas
|
||||||
|
|
||||||
|
### Métodos GameRoom Implementados
|
||||||
|
- `getInspectData()` - Datos completos para el admin (compatible con monitor oficial)
|
||||||
|
- `pauseGame()` - Pausar juego con broadcast a clientes
|
||||||
|
- `resumeGame()` - Reanudar juego con broadcast a clientes
|
||||||
|
- `advanceRound()` - Avanzar ronda con límite máximo 10
|
||||||
|
- `previousRound()` - Retroceder ronda con límite mínimo 1
|
||||||
|
- `_forceClientDisconnect(sessionId)` - Expulsar jugador con notificación
|
||||||
|
- `_forceDisconnectAllClients()` - Expulsar todos con notificaciones
|
||||||
|
|
||||||
|
### Comunicación Cliente-Servidor
|
||||||
|
**Mensajes del servidor a clientes:**
|
||||||
|
- `adminKicked` - Notificación de expulsión individual
|
||||||
|
- `gamePaused` - Notificación de pausa del juego
|
||||||
|
- `gameResumed` - Notificación de reanudación del juego
|
||||||
|
- `roundChanged` - Notificación de cambio de ronda global
|
||||||
|
|
||||||
|
**Manejo en el cliente:**
|
||||||
|
- Auto-redirección a home al recibir `adminKicked`
|
||||||
|
- Alerts informativos para cambios de estado
|
||||||
|
- Logging detallado de eventos administrativos
|
||||||
|
|
||||||
|
### Principios de Diseño
|
||||||
|
- **API Oficial Colyseus**: Sin hacks de variables globales
|
||||||
|
- **matchMaker.query()**: Acceso seguro a información de salas
|
||||||
|
- **matchMaker.remoteRoomCall()**: Ejecución remota de métodos
|
||||||
|
- **Type Safety**: Sincronización completa TypeScript
|
||||||
|
- **Error Handling**: Try/catch en todos los endpoints
|
||||||
|
- **Graceful Notifications**: Delay de 1 segundo para procesar mensajes
|
||||||
|
|
||||||
## Notas Específicas
|
## Notas Específicas
|
||||||
- **Offline**: Sin dependencias externas de internet
|
- **Offline**: Sin dependencias externas de internet
|
||||||
- **Microservicios**: Arquitectura separada por responsabilidades
|
- **Microservicios**: Arquitectura separada por responsabilidades
|
||||||
@@ -185,3 +250,4 @@ client/src/types/
|
|||||||
- **Variables de Entorno**: Configuración por ambiente (.env)
|
- **Variables de Entorno**: Configuración por ambiente (.env)
|
||||||
- **Logging**: Detallado para debugging profesional
|
- **Logging**: Detallado para debugging profesional
|
||||||
- **Tipos TypeScript**: Auto-generación con schema-codegen + copiar tipos auxiliares manualmente
|
- **Tipos TypeScript**: Auto-generación con schema-codegen + copiar tipos auxiliares manualmente
|
||||||
|
- **Admin Dashboard**: Completamente funcional con control total del juego
|
||||||
78
README.md
78
README.md
@@ -1,6 +1,6 @@
|
|||||||
# 🎮 SnatchGame
|
# 🎮 SnatchGame
|
||||||
|
|
||||||
[](https://github.com/username/snatchgame)
|
[](https://github.com/username/snatchgame)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://nodejs.org/)
|
[](https://nodejs.org/)
|
||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
Un juego multijugador educativo que simula la evolución de instituciones y cooperación, basado en el **"Snatch Game"** de **Elinor Ostrom**. Construido con **Colyseus.io** y **Vue 3** para redes locales.
|
Un juego multijugador educativo que simula la evolución de instituciones y cooperación, basado en el **"Snatch Game"** de **Elinor Ostrom**. Construido con **Colyseus.io** y **Vue 3** para redes locales.
|
||||||
|
|
||||||
> ⚠️ **Proyecto en desarrollo** - Actualmente en fase Alpha (v0.0.5-alpha)
|
> ✨ **Admin Dashboard Completo** - Versión Alpha (v0.0.8-alpha) con interfaz de administración profesional
|
||||||
|
|
||||||
## 🎓 Sobre el Juego
|
## 🎓 Sobre el Juego
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ Un juego multijugador educativo que simula la evolución de instituciones y coop
|
|||||||
- **📱 Responsive** - Interfaz optimizada para desktop y móvil
|
- **📱 Responsive** - Interfaz optimizada para desktop y móvil
|
||||||
- **🎯 Red local** - Funciona completamente offline
|
- **🎯 Red local** - Funciona completamente offline
|
||||||
- **📈 Progresión por rondas** - 5 rondas con reglas evolutivas
|
- **📈 Progresión por rondas** - 5 rondas con reglas evolutivas
|
||||||
|
- **🎛️ Admin Dashboard** - Interfaz completa de administración y monitoreo
|
||||||
|
- **🔔 Notificaciones** - Sistema completo de alertas para jugadores
|
||||||
|
|
||||||
## 🎮 Cómo Jugar
|
## 🎮 Cómo Jugar
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ cd server && npm install
|
|||||||
# Cliente
|
# Cliente
|
||||||
cd ../client && npm install
|
cd ../client && npm install
|
||||||
|
|
||||||
# Admin (próximamente)
|
# Admin
|
||||||
cd ../admin && npm install
|
cd ../admin && npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -116,7 +118,7 @@ npm run dev
|
|||||||
cd client
|
cd client
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Terminal 3 - Admin (opcional)
|
# Terminal 3 - Admin Dashboard
|
||||||
cd admin
|
cd admin
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -124,7 +126,8 @@ npm run dev
|
|||||||
### URLs de desarrollo
|
### URLs de desarrollo
|
||||||
- **Cliente**: http://localhost:3000
|
- **Cliente**: http://localhost:3000
|
||||||
- **Servidor**: http://localhost:2567
|
- **Servidor**: http://localhost:2567
|
||||||
- **Admin**: http://localhost:3001 (próximamente)
|
- **Admin Dashboard**: http://localhost:3001
|
||||||
|
- **Monitor Colyseus**: http://localhost:2567/monitor
|
||||||
|
|
||||||
### Producción
|
### Producción
|
||||||
```bash
|
```bash
|
||||||
@@ -155,6 +158,45 @@ docker-compose up -d
|
|||||||
- **MakeOfferForm**: Formulario con botones +/- intuitivos
|
- **MakeOfferForm**: Formulario con botones +/- intuitivos
|
||||||
- **OfferModal**: Modal flotante para crear ofertas dirigidas
|
- **OfferModal**: Modal flotante para crear ofertas dirigidas
|
||||||
|
|
||||||
|
## 🎛️ Admin Dashboard
|
||||||
|
|
||||||
|
El **Admin Dashboard** proporciona control completo y monitoreo en tiempo real del juego, diseñado tanto para administradores técnicos como para comentaristas no-técnicos.
|
||||||
|
|
||||||
|
### 📊 Características Principales
|
||||||
|
|
||||||
|
#### **Información en Tiempo Real**
|
||||||
|
- **👥 Lista de Jugadores Detallada**: Nombre, sala, rol, tipo de productor y tokens actuales
|
||||||
|
- **📈 Estadísticas Globales**: Jugadores conectados, partidas activas, ronda actual
|
||||||
|
- **🎯 Estado del Juego**: En progreso, pausado, esperando jugadores
|
||||||
|
- **🔄 Actualización Automática**: SSE con polling cada 500ms
|
||||||
|
|
||||||
|
#### **Control de Jugadores**
|
||||||
|
- **🚫 Expulsar Jugador Individual**: Con notificación al cliente y redirección automática
|
||||||
|
- **🚫🚫 Expulsar Todos los Jugadores**: Vaciar todas las salas con notificaciones apropiadas
|
||||||
|
- **👤 Información Detallada**: Ver tokens específicos (🦃 pavos, ☕ café, 🌽 maíz)
|
||||||
|
|
||||||
|
#### **Control del Juego**
|
||||||
|
- **⏸️ Pausar Juego**: Pausar todas las partidas activas
|
||||||
|
- **▶️ Reanudar Juego**: Reanudar partidas pausadas
|
||||||
|
- **⏮️ Retroceder Ronda**: Cambio global a ronda anterior (mínimo 1)
|
||||||
|
- **⏭️ Avanzar Ronda**: Cambio global a ronda siguiente (máximo 10)
|
||||||
|
|
||||||
|
#### **Notificaciones a Clientes**
|
||||||
|
- **🔔 Expulsión**: Mensaje personalizado y redirección automática
|
||||||
|
- **🎯 Cambio de Ronda**: Notificación inmediata de nuevas rondas
|
||||||
|
- **⏸️ Estado del Juego**: Alertas de pausa/reanudación
|
||||||
|
|
||||||
|
### 🎯 Usuarios Objetivo
|
||||||
|
- **👨💼 Administrador No-Técnico**: Vista limpia con estadísticas esenciales
|
||||||
|
- **👨💻 IT Profesional**: Información de debugging y estado técnico detallado
|
||||||
|
- **🎙️ Comentaristas Deportivos**: Información clara para narración en vivo
|
||||||
|
|
||||||
|
### 🏗️ Arquitectura Técnica
|
||||||
|
- **API Oficial Colyseus**: Uso de `matchMaker.query()` y `matchMaker.remoteRoomCall()`
|
||||||
|
- **Comunicación Bidireccional**: SSE para updates, HTTP para control
|
||||||
|
- **Sin Variables Globales**: Implementación limpia y mantenible
|
||||||
|
- **Type Safety**: Sincronización completa de tipos TypeScript
|
||||||
|
|
||||||
## ⚙️ Configuración
|
## ⚙️ Configuración
|
||||||
|
|
||||||
### Variables de Entorno
|
### Variables de Entorno
|
||||||
@@ -183,7 +225,7 @@ PORT=2567
|
|||||||
```
|
```
|
||||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
│ Client UI │ │ Colyseus │ │ Admin UI │
|
│ Client UI │ │ Colyseus │ │ Admin UI │
|
||||||
│ (Vue 3) │◄──►│ Server │◄──►│ (Vue 3) │
|
│ (Vue 3) │◄──►│ Server │◄──►│ (Vue 3 + SSE) │
|
||||||
│ Port 3000 │ │ Port 2567 │ │ Port 3001 │
|
│ Port 3000 │ │ Port 2567 │ │ Port 3001 │
|
||||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
@@ -195,6 +237,11 @@ PORT=2567
|
|||||||
└─────────────────┘
|
└─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Comunicación Admin
|
||||||
|
- **SSE (Server-Sent Events)**: Servidor → Admin UI
|
||||||
|
- **Polling**: Actualización cada 500ms
|
||||||
|
- **Control**: Admin → Servidor (HTTP endpoints)
|
||||||
|
|
||||||
### Sincronización de Tipos
|
### Sincronización de Tipos
|
||||||
```bash
|
```bash
|
||||||
# Los tipos se generan automáticamente del servidor al cliente
|
# Los tipos se generan automáticamente del servidor al cliente
|
||||||
@@ -247,7 +294,14 @@ snatchgame/
|
|||||||
│ │ └── main.ts
|
│ │ └── main.ts
|
||||||
│ ├── server.js # Express server (producción)
|
│ ├── server.js # Express server (producción)
|
||||||
│ └── README.md # Documentación del cliente
|
│ └── README.md # Documentación del cliente
|
||||||
├── 📁 admin/ # Admin dashboard (próximamente)
|
├── 📁 admin/ # Admin dashboard
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # Componentes Vue admin
|
||||||
|
│ │ ├── services/ # Admin service
|
||||||
|
│ │ │ └── adminService.ts
|
||||||
|
│ │ └── main.ts
|
||||||
|
│ ├── server.js # Express server (producción)
|
||||||
|
│ └── README.md # Documentación del admin
|
||||||
├── 🎮 gameRules.md # Reglas del juego detalladas
|
├── 🎮 gameRules.md # Reglas del juego detalladas
|
||||||
├── 🐳 docker-compose.yml # Orquestación Docker
|
├── 🐳 docker-compose.yml # Orquestación Docker
|
||||||
├── 📋 CLAUDE.md # Guía de desarrollo
|
├── 📋 CLAUDE.md # Guía de desarrollo
|
||||||
@@ -333,7 +387,15 @@ npm run start
|
|||||||
- [ ] 📊 Gráficos y visualizaciones
|
- [ ] 📊 Gráficos y visualizaciones
|
||||||
|
|
||||||
### Infraestructura
|
### Infraestructura
|
||||||
- [ ] 📈 UI de administración completa
|
- [x] 📈 UI de administración completa
|
||||||
|
- [x] Dashboard con estadísticas en tiempo real
|
||||||
|
- [x] Panel de control para administrar partidas
|
||||||
|
- [x] Sistema de expulsión de jugadores
|
||||||
|
- [x] Pausa/reanudación de partidas
|
||||||
|
- [x] Control de rondas globales
|
||||||
|
- [x] Información detallada de jugadores con tokens
|
||||||
|
- [x] Notificaciones automáticas a clientes
|
||||||
|
- [ ] Historial de partidas anteriores
|
||||||
- [ ] 🐳 Docker en producción
|
- [ ] 🐳 Docker en producción
|
||||||
- [ ] 📱 PWA support
|
- [ ] 📱 PWA support
|
||||||
- [ ] 🌍 Multi-idioma (EN/ES)
|
- [ ] 🌍 Multi-idioma (EN/ES)
|
||||||
|
|||||||
15
TODO.md
15
TODO.md
@@ -23,3 +23,18 @@ Type 'GameClient' is missing the following properties from type 'GameClient': cl
|
|||||||
- O definir tipos de props más explícitos
|
- O definir tipos de props más explícitos
|
||||||
|
|
||||||
**Prioridad:** Media (funciona pero no es tipo-seguro)
|
**Prioridad:** Media (funciona pero no es tipo-seguro)
|
||||||
|
|
||||||
|
### Admin API - Métodos pauseGame y resumeGame
|
||||||
|
**Problema:** Los endpoints `/api/admin/pause-game` y `/api/admin/resume-game` usan `matchMaker.remoteRoomCall` para llamar métodos que no existen en GameRoom.
|
||||||
|
|
||||||
|
**Archivos afectados:**
|
||||||
|
- `server/src/app.config.ts` - Endpoints que llaman a `pauseGame` y `resumeGame`
|
||||||
|
- `server/src/rooms/GameRoom.ts` - Falta implementar los métodos
|
||||||
|
|
||||||
|
**Solución pendiente:**
|
||||||
|
- Implementar método `pauseGame()` en GameRoom.ts
|
||||||
|
- Implementar método `resumeGame()` en GameRoom.ts
|
||||||
|
- Los métodos deben modificar `gamePhase` en el estado del juego
|
||||||
|
- Agregar logs apropiados para debugging
|
||||||
|
|
||||||
|
**Prioridad:** Alta (endpoints fallarán hasta que se implemente)
|
||||||
358
admin/README.md
Normal file
358
admin/README.md
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
# 🎛️ SnatchGame Admin Dashboard
|
||||||
|
|
||||||
|
[](https://github.com/username/snatchgame)
|
||||||
|
[](https://vuejs.org/)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
|
||||||
|
**Interfaz de administración completa** para el control y monitoreo en tiempo real del juego **SnatchGame**. Diseñada para administradores técnicos, personal no-técnico y comentaristas deportivos.
|
||||||
|
|
||||||
|
## 📊 Características Principales
|
||||||
|
|
||||||
|
### **🔄 Monitoreo en Tiempo Real**
|
||||||
|
- **👥 Lista de Jugadores Detallada**: Nombre, sala, rol, tipo de productor
|
||||||
|
- **🎯 Estado de Tokens**: Cantidad actual de 🦃 pavos, ☕ café, 🌽 maíz por jugador
|
||||||
|
- **📈 Estadísticas Globales**: Jugadores conectados, partidas activas, ronda actual
|
||||||
|
- **🔗 Estado de Conexión**: Indicador visual de jugadores conectados/desconectados
|
||||||
|
- **⚡ Actualización Automática**: SSE con polling cada 500ms
|
||||||
|
|
||||||
|
### **🎮 Control del Juego**
|
||||||
|
- **⏮️ Retroceder Ronda**: Cambio global a ronda anterior (mínimo 1)
|
||||||
|
- **⏭️ Avanzar Ronda**: Cambio global a ronda siguiente (máximo 10)
|
||||||
|
- **⏸️ Pausar Juego**: Pausar todas las partidas activas
|
||||||
|
- **▶️ Reanudar Juego**: Reanudar partidas pausadas
|
||||||
|
|
||||||
|
### **👥 Gestión de Jugadores**
|
||||||
|
- **🚫 Expulsar Jugador Individual**: Con notificación automática al cliente
|
||||||
|
- **🚫🚫 Expulsar Todos los Jugadores**: Vaciar todas las salas con notificaciones
|
||||||
|
- **🏠 Redirección Automática**: Los jugadores expulsados vuelven al home
|
||||||
|
- **📱 Notificaciones Inmediatas**: Alerts personalizados para cada acción
|
||||||
|
|
||||||
|
### **🎯 Usuarios Objetivo**
|
||||||
|
- **👨💼 Administrador No-Técnico**: Vista limpia con estadísticas esenciales
|
||||||
|
- **👨💻 IT Profesional**: Información de debugging y estado técnico
|
||||||
|
- **🎙️ Comentaristas Deportivos**: Información clara para narración en vivo
|
||||||
|
|
||||||
|
## 🏗️ Arquitectura Técnica
|
||||||
|
|
||||||
|
### **Stack Tecnológico**
|
||||||
|
- **Frontend**: Vue 3 + Composition API + TypeScript
|
||||||
|
- **Build Tool**: Vite (desarrollo) + Express (producción)
|
||||||
|
- **Comunicación**: HTTP (control) + fetch API
|
||||||
|
- **Styling**: CSS vanilla con diseño responsivo
|
||||||
|
- **Types**: Auto-generados desde servidor con schema-codegen
|
||||||
|
|
||||||
|
### **Comunicación con Servidor**
|
||||||
|
```typescript
|
||||||
|
// Admin Service comunica con servidor Colyseus
|
||||||
|
adminService.kickPlayer(playerId) // → POST /api/admin/kick-player
|
||||||
|
adminService.advanceRound() // → POST /api/admin/advance-round
|
||||||
|
adminService.pauseGame() // → POST /api/admin/pause-game
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Arquitectura del Sistema**
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Admin UI │ │ Colyseus │ │ Game Client │
|
||||||
|
│ Port 3001 │───▶│ Server │◄──▶│ Port 3000 │
|
||||||
|
│ (Vue 3) │ │ Port 2567 │ │ (Vue 3) │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
│ │ │
|
||||||
|
└───── HTTP/Fetch ──────┘ │
|
||||||
|
│ │
|
||||||
|
┌───────────▼───────────┐ │
|
||||||
|
│ Notifications │ │
|
||||||
|
│ (adminKicked, │◄─────────┘
|
||||||
|
│ roundChanged) │
|
||||||
|
└───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Instalación y Ejecución
|
||||||
|
|
||||||
|
### **Prerrequisitos**
|
||||||
|
- Node.js >= 18.0.0
|
||||||
|
- npm >= 8.0.0
|
||||||
|
- Servidor Colyseus ejecutándose en puerto 2567
|
||||||
|
|
||||||
|
### **Desarrollo**
|
||||||
|
```bash
|
||||||
|
# Desde el directorio admin
|
||||||
|
cd admin
|
||||||
|
|
||||||
|
# Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Generar tipos desde servidor
|
||||||
|
npm run generate-types
|
||||||
|
|
||||||
|
# Iniciar desarrollo
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Producción**
|
||||||
|
```bash
|
||||||
|
# Build para producción
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Iniciar servidor Express
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### **URLs**
|
||||||
|
- **Desarrollo**: http://localhost:3001
|
||||||
|
- **Producción**: Configurado según variables de entorno
|
||||||
|
|
||||||
|
## 📁 Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
admin/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Componentes Vue
|
||||||
|
│ ├── services/ # Servicios de comunicación
|
||||||
|
│ │ └── adminService.ts # API client para servidor Colyseus
|
||||||
|
│ ├── types/ # Tipos auto-generados
|
||||||
|
│ │ ├── GameState.ts # Estado del juego
|
||||||
|
│ │ ├── Player.ts # Información de jugador
|
||||||
|
│ │ ├── TokenInventory.ts # Inventario de tokens
|
||||||
|
│ │ └── index.ts # Re-exports y tipos auxiliares
|
||||||
|
│ ├── App.vue # Componente principal
|
||||||
|
│ └── main.ts # Entry point
|
||||||
|
├── server.js # Express server (producción)
|
||||||
|
├── package.json # Dependencias y scripts
|
||||||
|
├── vite.config.ts # Configuración Vite
|
||||||
|
└── README.md # Este archivo
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Interfaz de Usuario
|
||||||
|
|
||||||
|
### **Dashboard Principal**
|
||||||
|
```
|
||||||
|
📊 SnatchGame Dashboard 🟢 Conectado
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 👥 Jugadores: 6 🎮 Partidas: 2 🎯 Ronda: 3 │
|
||||||
|
│ 📊 Estado: En progreso │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
🎛️ Control del Juego
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ ⏮️ Retroceder ⏭️ Avanzar ⏸️ Pausar ▶️ Reanudar │
|
||||||
|
│ Ronda Ronda Juego Juego │
|
||||||
|
│ │
|
||||||
|
│ 🚫 Expulsar 🚫🚫 Expulsar │
|
||||||
|
│ Jugador Jugadores │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
👥 Lista de Jugadores (6)
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Juan Carlos 🟢 Sala: a3f2b1 │
|
||||||
|
│ Comerciante 🦃 Productor de Pavos │
|
||||||
|
│ 🦃 3 ☕ 2 🌽 1 🚫 Expulsar │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ María López 🟢 Sala: a3f2b1 │
|
||||||
|
│ Comerciante ☕ Productor de Café │
|
||||||
|
│ 🦃 1 ☕ 4 🌽 2 🚫 Expulsar │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Responsive Design**
|
||||||
|
- **Desktop**: Layout de 2 columnas con información completa
|
||||||
|
- **Tablet**: Layout adaptativo con botones optimizados
|
||||||
|
- **Mobile**: Layout vertical con información esencial
|
||||||
|
|
||||||
|
## ⚙️ Configuración
|
||||||
|
|
||||||
|
### **Variables de Entorno**
|
||||||
|
```env
|
||||||
|
# .env.development
|
||||||
|
VITE_ADMIN_PORT=3001
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# .env.production
|
||||||
|
VITE_ADMIN_PORT=3001
|
||||||
|
NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
### **AdminService Configuration**
|
||||||
|
```typescript
|
||||||
|
// src/services/adminService.ts
|
||||||
|
class AdminService {
|
||||||
|
private serverUrl = 'http://localhost:2567' // Colyseus server
|
||||||
|
|
||||||
|
// Todos los métodos apuntan al servidor Colyseus
|
||||||
|
async kickPlayer(playerId: string) { /* ... */ }
|
||||||
|
async advanceRound() { /* ... */ }
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 API Reference
|
||||||
|
|
||||||
|
### **AdminService Methods**
|
||||||
|
|
||||||
|
#### **Control de Jugadores**
|
||||||
|
```typescript
|
||||||
|
// Expulsar jugador específico
|
||||||
|
await adminService.kickPlayer('player-session-id')
|
||||||
|
|
||||||
|
// Expulsar todos los jugadores
|
||||||
|
await adminService.kickAllPlayers()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Control del Juego**
|
||||||
|
```typescript
|
||||||
|
// Pausar todas las partidas
|
||||||
|
await adminService.pauseGame()
|
||||||
|
|
||||||
|
// Reanudar partidas pausadas
|
||||||
|
await adminService.resumeGame()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Control de Rondas**
|
||||||
|
```typescript
|
||||||
|
// Avanzar ronda globalmente
|
||||||
|
await adminService.advanceRound()
|
||||||
|
|
||||||
|
// Retroceder ronda globalmente
|
||||||
|
await adminService.previousRound()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Gestión de Partidas**
|
||||||
|
```typescript
|
||||||
|
// Cancelar partida específica
|
||||||
|
await adminService.cancelGame('room-id')
|
||||||
|
|
||||||
|
// Cancelar todas las partidas
|
||||||
|
await adminService.cancelGame()
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Endpoints del Servidor**
|
||||||
|
Todos los endpoints están en el **servidor Colyseus** (puerto 2567):
|
||||||
|
|
||||||
|
- `GET /api/admin/stats` - Estadísticas en tiempo real
|
||||||
|
- `POST /api/admin/kick-player` - Expulsar jugador específico
|
||||||
|
- `POST /api/admin/kick-all-players` - Expulsar todos los jugadores
|
||||||
|
- `POST /api/admin/pause-game` - Pausar juego
|
||||||
|
- `POST /api/admin/resume-game` - Reanudar juego
|
||||||
|
- `POST /api/admin/advance-round` - Avanzar ronda
|
||||||
|
- `POST /api/admin/previous-round` - Retroceder ronda
|
||||||
|
- `POST /api/admin/cancel-game` - Cancelar partida
|
||||||
|
|
||||||
|
## 🔔 Sistema de Notificaciones
|
||||||
|
|
||||||
|
### **Notificaciones a Clientes**
|
||||||
|
El admin puede enviar notificaciones automáticas que los clientes reciben:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// El cliente recibe estos mensajes automáticamente
|
||||||
|
client.onMessage("adminKicked", (data) => {
|
||||||
|
alert("🚫 Has sido expulsado por el administrador")
|
||||||
|
// Auto-redirección a home screen
|
||||||
|
})
|
||||||
|
|
||||||
|
client.onMessage("roundChanged", (data) => {
|
||||||
|
alert(`🎯 ${data.message}`) // "Ronda 3 - Cambio realizado por el administrador"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Experiencia del Usuario**
|
||||||
|
1. **Admin ejecuta acción** (expulsar, cambiar ronda, etc.)
|
||||||
|
2. **Servidor procesa** usando API oficial de Colyseus
|
||||||
|
3. **Clientes reciben notificación** inmediata y automática
|
||||||
|
4. **Redirección automática** cuando corresponde (expulsión)
|
||||||
|
|
||||||
|
## 🛠️ Desarrollo
|
||||||
|
|
||||||
|
### **Scripts Disponibles**
|
||||||
|
```bash
|
||||||
|
# Desarrollo con hot reload
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Generar tipos desde servidor
|
||||||
|
npm run generate-types
|
||||||
|
|
||||||
|
# Build para producción
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Preview del build
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# Servidor Express (producción)
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Debugging**
|
||||||
|
```bash
|
||||||
|
# Verificar que el servidor Colyseus esté ejecutándose
|
||||||
|
curl http://localhost:2567/
|
||||||
|
|
||||||
|
# Verificar endpoint de stats
|
||||||
|
curl http://localhost:2567/api/admin/stats
|
||||||
|
|
||||||
|
# Ver logs del admin en desarrollo
|
||||||
|
npm run dev
|
||||||
|
# Abrir DevTools (F12) para logs detallados
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Type Generation**
|
||||||
|
```bash
|
||||||
|
# Los tipos se generan automáticamente desde el servidor
|
||||||
|
cd admin
|
||||||
|
npm run generate-types
|
||||||
|
|
||||||
|
# Esto ejecuta:
|
||||||
|
# cd ../server && npx schema-codegen src/rooms/GameRoom.ts --ts --output ../admin/src/types/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Deploy
|
||||||
|
|
||||||
|
### **Docker (Recomendado)**
|
||||||
|
```bash
|
||||||
|
# Desde el directorio raíz del proyecto
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# El admin estará disponible en el puerto configurado
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Manual**
|
||||||
|
```bash
|
||||||
|
# Build admin
|
||||||
|
cd admin
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start en producción
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contribuir
|
||||||
|
|
||||||
|
### **Estructura de Contribución**
|
||||||
|
1. **Servidor primero**: Implementar nuevos endpoints en `/server/src/app.config.ts`
|
||||||
|
2. **GameRoom methods**: Agregar métodos necesarios en `/server/src/rooms/GameRoom.ts`
|
||||||
|
3. **AdminService**: Actualizar `/admin/src/services/adminService.ts`
|
||||||
|
4. **UI Components**: Modificar `/admin/src/App.vue` según necesidad
|
||||||
|
5. **Types**: Regenerar con `npm run generate-types`
|
||||||
|
|
||||||
|
### **Convenciones**
|
||||||
|
- **Endpoints**: Prefijo `/api/admin/` para todas las rutas admin
|
||||||
|
- **Métodos GameRoom**: Prefijo `_` para métodos admin (ej: `_forceClientDisconnect`)
|
||||||
|
- **Notificaciones**: Mensajes descriptivos en español para usuarios
|
||||||
|
- **Error Handling**: Try/catch en todos los métodos async
|
||||||
|
|
||||||
|
## 📄 Licencia
|
||||||
|
|
||||||
|
Este microservicio es parte del proyecto **SnatchGame** bajo licencia **MIT**.
|
||||||
|
|
||||||
|
## 🙋♂️ Soporte
|
||||||
|
|
||||||
|
- **Documentación Principal**: `/README.md` (directorio raíz)
|
||||||
|
- **Guía Técnica**: `/CLAUDE.md`
|
||||||
|
- **Issues**: [GitHub Issues](https://github.com/username/snatchgame/issues)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**🎛️ Admin Dashboard Completo para SnatchGame**
|
||||||
|
|
||||||
|
*Control total • Monitoreo en tiempo real • Notificaciones automáticas*
|
||||||
|
|
||||||
|
</div>
|
||||||
12
admin/index.html
Normal file
12
admin/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Snatch Game - Admin Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2917
admin/package-lock.json
generated
Normal file
2917
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
admin/package.json
Normal file
39
admin/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "snatchgame-admin",
|
||||||
|
"version": "0.0.8-alpha",
|
||||||
|
"description": "SnatchGame admin dashboard server",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run generate-types && vite",
|
||||||
|
"build": "npm run generate-types && vue-tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"serve": "PORT=3002 nodemon server.js",
|
||||||
|
"start": "NODE_ENV=production node server.js",
|
||||||
|
"generate-types": "cd ../server && npx schema-codegen src/rooms/GameRoom.ts --ts --output ../admin/src/types/",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"express",
|
||||||
|
"vue",
|
||||||
|
"admin",
|
||||||
|
"dashboard"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"colyseus.js": "^0.16.19",
|
||||||
|
"dotenv": "^16.0.0",
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^7.0.0",
|
||||||
|
"vue": "^3.5.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
|
"vue-tsc": "^3.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
172
admin/server.js
Normal file
172
admin/server.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
const ENV = process.env.NODE_ENV || 'development';
|
||||||
|
dotenv.config({ path: `.env.${ENV}` });
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3002;
|
||||||
|
|
||||||
|
// Parse JSON bodies
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
console.log('que pedos');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'healthy',
|
||||||
|
service: 'snatchgame-admin',
|
||||||
|
environment: ENV,
|
||||||
|
serverUrl: process.env.SERVER_URL
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API endpoint to get environment config for client
|
||||||
|
app.get('/api/config', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
serverUrl: process.env.SERVER_URL,
|
||||||
|
environment: ENV
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSE endpoint for real-time updates
|
||||||
|
app.get('/api/sse', (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initial connection message
|
||||||
|
res.write('data: {"type": "connected", "message": "SSE connection established"}\n\n');
|
||||||
|
|
||||||
|
// Set up polling interval for game state updates
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch game state from Colyseus server
|
||||||
|
const gameServerUrl = process.env.SERVER_URL || 'http://localhost:2567';
|
||||||
|
const response = await fetch(`${gameServerUrl}/api/admin/stats`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const gameStats = await response.json();
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
type: 'gameStats',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
data: gameStats
|
||||||
|
})}\n\n`);
|
||||||
|
} else {
|
||||||
|
// Send error status if server is not reachable
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message: 'Cannot connect to game server'
|
||||||
|
})}\n\n`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching game stats:', error);
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message: 'Error fetching game stats'
|
||||||
|
})}\n\n`);
|
||||||
|
}
|
||||||
|
}, 500); // Poll every 500ms
|
||||||
|
|
||||||
|
// Clean up on client disconnect
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin control endpoints - proxy to Colyseus server
|
||||||
|
|
||||||
|
// Kick player endpoint
|
||||||
|
app.post('/api/admin/kick-player', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const gameServerUrl = process.env.SERVER_URL || 'http://localhost:2567';
|
||||||
|
const response = await fetch(`${gameServerUrl}/api/admin/kick-player`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(req.body)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, message: 'Error communicating with game server' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pause game endpoint
|
||||||
|
app.post('/api/admin/pause-game', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const gameServerUrl = process.env.SERVER_URL || 'http://localhost:2567';
|
||||||
|
const response = await fetch(`${gameServerUrl}/api/admin/pause-game`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(req.body)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, message: 'Error communicating with game server' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resume game endpoint
|
||||||
|
app.post('/api/admin/resume-game', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const gameServerUrl = process.env.SERVER_URL || 'http://localhost:2567';
|
||||||
|
const response = await fetch(`${gameServerUrl}/api/admin/resume-game`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(req.body)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, message: 'Error communicating with game server' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel game endpoint
|
||||||
|
app.post('/api/admin/cancel-game', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const gameServerUrl = process.env.SERVER_URL || 'http://localhost:2567';
|
||||||
|
const response = await fetch(`${gameServerUrl}/api/admin/cancel-game`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(req.body)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, message: 'Error communicating with game server' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files from current directory (AFTER API routes)
|
||||||
|
app.use(express.static('.'));
|
||||||
|
|
||||||
|
// Serve main HTML file for SPA routes
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`
|
||||||
|
📊 SnatchGame Admin Dashboard
|
||||||
|
📱 Environment: ${ENV}
|
||||||
|
🌐 Server URL: http://localhost:${PORT}
|
||||||
|
🔗 Game Server: ${process.env.SERVER_URL}
|
||||||
|
📡 SSE Endpoint: http://localhost:${PORT}/api/sse
|
||||||
|
`);
|
||||||
|
});
|
||||||
553
admin/src/App.vue
Normal file
553
admin/src/App.vue
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-app">
|
||||||
|
<header class="admin-header">
|
||||||
|
<h1>📊 SnatchGame Admin Dashboard</h1>
|
||||||
|
<div class="connection-status" :class="{ connected: isConnected }">
|
||||||
|
{{ isConnected ? '🟢 Conectado' : '🔴 Desconectado' }}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="admin-main">
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<!-- Game Stats Cards -->
|
||||||
|
<div class="stats-card">
|
||||||
|
<h3>👥 Jugadores Conectados</h3>
|
||||||
|
<div class="stat-value">{{ gameStats.connectedPlayers || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-card">
|
||||||
|
<h3>🎮 Partidas Activas</h3>
|
||||||
|
<div class="stat-value">{{ gameStats.activeGames || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-card">
|
||||||
|
<h3>🎯 Ronda Actual</h3>
|
||||||
|
<div class="stat-value">{{ gameStats.currentRound || 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-card">
|
||||||
|
<h3>📊 Estado del Juego</h3>
|
||||||
|
<div class="stat-value">{{ getGameStateText(gameStats.gameState) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Controls -->
|
||||||
|
<div class="admin-controls">
|
||||||
|
<h3>🎛️ Control del Juego</h3>
|
||||||
|
<div class="control-buttons">
|
||||||
|
<button @click="previousRound" :disabled="!isConnected" class="btn btn-secondary">
|
||||||
|
⏮️ Retroceder Ronda
|
||||||
|
</button>
|
||||||
|
<button @click="advanceRound" :disabled="!isConnected" class="btn btn-primary">
|
||||||
|
⏭️ Avanzar Ronda
|
||||||
|
</button>
|
||||||
|
<button @click="pauseGame" :disabled="!isConnected" class="btn btn-warning">
|
||||||
|
⏸️ Pausar Juego
|
||||||
|
</button>
|
||||||
|
<button @click="resumeGame" :disabled="!isConnected" class="btn btn-success">
|
||||||
|
▶️ Reanudar Juego
|
||||||
|
</button>
|
||||||
|
<button @click="showKickPlayerModal" :disabled="!isConnected" class="btn btn-danger">
|
||||||
|
🚫 Expulsar Jugador
|
||||||
|
</button>
|
||||||
|
<button @click="kickAllPlayers" :disabled="!isConnected" class="btn btn-danger btn-destructive">
|
||||||
|
🚫🚫 Expulsar Jugadores
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player List -->
|
||||||
|
<div class="player-list-section">
|
||||||
|
<h3>👥 Lista de Jugadores ({{ gameStats.players?.length || 0 }})</h3>
|
||||||
|
<div class="player-list">
|
||||||
|
<div v-if="gameStats.players && gameStats.players.length > 0">
|
||||||
|
<div v-for="player in gameStats.players" :key="player.id" class="player-item">
|
||||||
|
<div class="player-info">
|
||||||
|
<div class="player-header">
|
||||||
|
<span class="player-name">{{ player.name }}</span>
|
||||||
|
<span class="player-status" :class="{ connected: player.isConnected, disconnected: !player.isConnected }">
|
||||||
|
{{ player.isConnected ? '🟢' : '🔴' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-details">
|
||||||
|
<span class="player-room">Sala: {{ player.roomId?.slice(-6) || 'N/A' }}</span>
|
||||||
|
<span class="player-role">{{ getRoleText(player.role) }}</span>
|
||||||
|
<span class="player-producer">{{ getProducerText(player.producerRole) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-tokens">
|
||||||
|
<span class="token-item">🦃 {{ player.tokens?.turkeys || 0 }}</span>
|
||||||
|
<span class="token-item">☕ {{ player.tokens?.coffee || 0 }}</span>
|
||||||
|
<span class="token-item">🌽 {{ player.tokens?.corn || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="player-actions">
|
||||||
|
<button @click="kickPlayer(player.id)" class="btn btn-sm btn-danger">
|
||||||
|
🚫 Expulsar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-players">
|
||||||
|
Sin jugadores conectados
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug Info -->
|
||||||
|
<div class="debug-section">
|
||||||
|
<h3>🔧 Debug Info</h3>
|
||||||
|
<div class="debug-info">
|
||||||
|
<div><strong>Última actualización:</strong> {{ lastUpdate }}</div>
|
||||||
|
<div><strong>Conexión SSE:</strong> {{ isConnected ? 'Activa' : 'Inactiva' }}</div>
|
||||||
|
<div><strong>Servidor:</strong> {{ serverUrl }}</div>
|
||||||
|
</div>
|
||||||
|
<details class="raw-data">
|
||||||
|
<summary>Ver datos completos</summary>
|
||||||
|
<pre>{{ JSON.stringify(gameStats, null, 2) }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { adminService } from './services/adminService'
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
|
const isConnected = ref(false)
|
||||||
|
const gameStats = ref<any>({})
|
||||||
|
const lastUpdate = ref('')
|
||||||
|
const serverUrl = ref('')
|
||||||
|
|
||||||
|
// Initialize admin service
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// Get config from server
|
||||||
|
const configResponse = await fetch('/api/config')
|
||||||
|
const config = await configResponse.json()
|
||||||
|
serverUrl.value = config.serverUrl
|
||||||
|
|
||||||
|
// Connect to SSE
|
||||||
|
adminService.connect((data) => {
|
||||||
|
if (data.type === 'connected') {
|
||||||
|
isConnected.value = true
|
||||||
|
} else if (data.type === 'gameStats') {
|
||||||
|
gameStats.value = data.data
|
||||||
|
lastUpdate.value = data.timestamp
|
||||||
|
} else if (data.type === 'error') {
|
||||||
|
console.error('Admin service error:', data.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
adminService.onConnectionChange((connected) => {
|
||||||
|
isConnected.value = connected
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing admin dashboard:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
adminService.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const getGameStateText = (state: string) => {
|
||||||
|
const stateMap: { [key: string]: string } = {
|
||||||
|
'waiting_for_players': 'Esperando jugadores',
|
||||||
|
'paused': 'Pausado',
|
||||||
|
'game_over': 'Juego terminado',
|
||||||
|
'in_progress': 'En progreso'
|
||||||
|
}
|
||||||
|
return stateMap[state] || state || 'Desconocido'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleText = (role: string) => {
|
||||||
|
const roleMap: { [key: string]: string } = {
|
||||||
|
'trader': 'Comerciante',
|
||||||
|
'judge': 'Juez',
|
||||||
|
'player': 'Jugador'
|
||||||
|
}
|
||||||
|
return roleMap[role] || role || 'Desconocido'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProducerText = (producerRole: string) => {
|
||||||
|
const producerMap: { [key: string]: string } = {
|
||||||
|
'turkey': '🦃 Productor de Pavos',
|
||||||
|
'coffee': '☕ Productor de Café',
|
||||||
|
'corn': '🌽 Productor de Maíz'
|
||||||
|
}
|
||||||
|
return producerMap[producerRole] || producerRole || 'Desconocido'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin actions
|
||||||
|
const pauseGame = async () => {
|
||||||
|
try {
|
||||||
|
await adminService.pauseGame()
|
||||||
|
alert('Juego pausado exitosamente')
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al pausar el juego')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumeGame = async () => {
|
||||||
|
try {
|
||||||
|
await adminService.resumeGame()
|
||||||
|
alert('Juego reanudado exitosamente')
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al reanudar el juego')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kickPlayer = async (playerId: string) => {
|
||||||
|
if (confirm('¿Estás seguro de que quieres expulsar a este jugador?')) {
|
||||||
|
try {
|
||||||
|
await adminService.kickPlayer(playerId)
|
||||||
|
alert('Jugador expulsado exitosamente')
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al expulsar al jugador')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showKickPlayerModal = () => {
|
||||||
|
const playerId = prompt('Ingresa el ID del jugador a expulsar:')
|
||||||
|
if (playerId) {
|
||||||
|
kickPlayer(playerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kickAllPlayers = async () => {
|
||||||
|
const confirmation = confirm('⚠️ ¿Estás seguro de que quieres expulsar a TODOS los jugadores de TODAS las salas? Esta acción no se puede deshacer.')
|
||||||
|
if (confirmation) {
|
||||||
|
try {
|
||||||
|
await adminService.kickAllPlayers()
|
||||||
|
alert('Todos los jugadores han sido expulsados exitosamente')
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al expulsar a todos los jugadores')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const advanceRound = async () => {
|
||||||
|
try {
|
||||||
|
await adminService.advanceRound()
|
||||||
|
alert('Ronda avanzada exitosamente en todas las salas')
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al avanzar la ronda')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousRound = async () => {
|
||||||
|
try {
|
||||||
|
await adminService.previousRound()
|
||||||
|
alert('Ronda retrocedida exitosamente en todas las salas')
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al retroceder la ronda')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-app {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 0, 0, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.connected {
|
||||||
|
background: rgba(0, 255, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-controls {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background: #ff9800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #4caf50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #2196f3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #607d8b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-destructive {
|
||||||
|
background: #d32f2f !important;
|
||||||
|
border: 2px solid #b71c1c;
|
||||||
|
font-weight: 600;
|
||||||
|
animation: pulse-danger 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-destructive:hover:not(:disabled) {
|
||||||
|
background: #b71c1c !important;
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-danger {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 10px rgba(244, 67, 54, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-list-section {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-list {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-status.connected {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-status.disconnected {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-room {
|
||||||
|
color: #81c784;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-role {
|
||||||
|
color: #64b5f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-producer {
|
||||||
|
color: #ffb74d;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-tokens {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-actions {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-players {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-section {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-info {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-info div {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-data {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-data pre {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-item {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
4
admin/src/main.ts
Normal file
4
admin/src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
149
admin/src/services/adminService.ts
Normal file
149
admin/src/services/adminService.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
interface AdminStats {
|
||||||
|
connectedPlayers: number
|
||||||
|
activeGames: number
|
||||||
|
currentRound: string
|
||||||
|
gameState: string
|
||||||
|
players?: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminMessage {
|
||||||
|
type: 'connected' | 'gameStats' | 'error'
|
||||||
|
timestamp?: string
|
||||||
|
data?: AdminStats
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminCallback = (data: AdminMessage) => void
|
||||||
|
type ConnectionCallback = (connected: boolean) => void
|
||||||
|
|
||||||
|
class AdminService {
|
||||||
|
private eventSource: EventSource | null = null
|
||||||
|
private callback: AdminCallback | null = null
|
||||||
|
private connectionCallback: ConnectionCallback | null = null
|
||||||
|
private isConnected = false
|
||||||
|
private serverUrl: string = 'http://localhost:2567' // Default to Colyseus server
|
||||||
|
|
||||||
|
connect(callback: AdminCallback): void {
|
||||||
|
this.callback = callback
|
||||||
|
this.eventSource = new EventSource('/api/sse')
|
||||||
|
|
||||||
|
this.eventSource.onopen = () => {
|
||||||
|
this.isConnected = true
|
||||||
|
this.connectionCallback?.(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
this.callback?.(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing SSE message:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE connection error:', error)
|
||||||
|
this.isConnected = false
|
||||||
|
this.connectionCallback?.(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.eventSource) {
|
||||||
|
this.eventSource.close()
|
||||||
|
this.eventSource = null
|
||||||
|
}
|
||||||
|
this.isConnected = false
|
||||||
|
this.connectionCallback?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
onConnectionChange(callback: ConnectionCallback): void {
|
||||||
|
this.connectionCallback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin control methods
|
||||||
|
async kickPlayer(playerId: string): Promise<void> {
|
||||||
|
const response = await fetch(`${this.serverUrl}/api/admin/kick-player`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playerId })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to kick player')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pauseGame(): Promise<void> {
|
||||||
|
const response = await fetch(`${this.serverUrl}/api/admin/pause-game`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to pause game')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeGame(): Promise<void> {
|
||||||
|
const response = await fetch(`${this.serverUrl}/api/admin/resume-game`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to resume game')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelGame(gameId: string): Promise<void> {
|
||||||
|
const response = await fetch(`${this.serverUrl}/api/admin/cancel-game`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ gameId })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to cancel game')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async kickAllPlayers(): Promise<void> {
|
||||||
|
const response = await fetch(`${this.serverUrl}/api/admin/kick-all-players`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to kick all players')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async advanceRound(): Promise<void> {
|
||||||
|
const response = await fetch(`${this.serverUrl}/api/admin/advance-round`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to advance round')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async previousRound(): Promise<void> {
|
||||||
|
const response = await fetch(`${this.serverUrl}/api/admin/previous-round`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to go back round')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminService = new AdminService()
|
||||||
15
admin/src/types/TokenInventory.ts
Normal file
15
admin/src/types/TokenInventory.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// THIS FILE HAS BEEN GENERATED AUTOMATICALLY
|
||||||
|
// DO NOT CHANGE IT MANUALLY UNLESS YOU KNOW WHAT YOU'RE DOING
|
||||||
|
//
|
||||||
|
// GENERATED USING @colyseus/schema 3.0.42
|
||||||
|
//
|
||||||
|
|
||||||
|
import { Schema, type, ArraySchema, MapSchema, SetSchema, DataChange } from '@colyseus/schema';
|
||||||
|
|
||||||
|
|
||||||
|
export class TokenInventory extends Schema {
|
||||||
|
@type("number") public turkey!: number;
|
||||||
|
@type("number") public coffee!: number;
|
||||||
|
@type("number") public corn!: number;
|
||||||
|
}
|
||||||
18
admin/src/types/TradeOffer.ts
Normal file
18
admin/src/types/TradeOffer.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// THIS FILE HAS BEEN GENERATED AUTOMATICALLY
|
||||||
|
// DO NOT CHANGE IT MANUALLY UNLESS YOU KNOW WHAT YOU'RE DOING
|
||||||
|
//
|
||||||
|
// GENERATED USING @colyseus/schema 3.0.42
|
||||||
|
//
|
||||||
|
|
||||||
|
import { Schema, type, ArraySchema, MapSchema, SetSchema, DataChange } from '@colyseus/schema';
|
||||||
|
import { TokenInventory } from './TokenInventory'
|
||||||
|
|
||||||
|
export class TradeOffer extends Schema {
|
||||||
|
@type("string") public id!: string;
|
||||||
|
@type("string") public offererId!: string;
|
||||||
|
@type("string") public targetId!: string;
|
||||||
|
@type(TokenInventory) public offering: TokenInventory = new TokenInventory();
|
||||||
|
@type(TokenInventory) public requesting: TokenInventory = new TokenInventory();
|
||||||
|
@type("string") public status!: string;
|
||||||
|
}
|
||||||
30
admin/src/types/index.ts
Normal file
30
admin/src/types/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Admin-specific types
|
||||||
|
export interface AdminStats {
|
||||||
|
connectedPlayers: number
|
||||||
|
activeGames: number
|
||||||
|
currentRound: string
|
||||||
|
gameState: string
|
||||||
|
players?: AdminPlayer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPlayer {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
roomId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMessage {
|
||||||
|
type: 'connected' | 'gameStats' | 'error'
|
||||||
|
timestamp?: string
|
||||||
|
data?: AdminStats
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminConfig {
|
||||||
|
serverUrl: string
|
||||||
|
environment: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game-related types will be auto-generated from server using schema-codegen
|
||||||
|
// Run: npm run generate-types
|
||||||
15
admin/tsconfig.json
Normal file
15
admin/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
admin/vite.config.ts
Normal file
28
admin/vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/health': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3002',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist'
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': '/src'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "snatchgame-client",
|
"name": "snatchgame-client",
|
||||||
"version": "0.0.5-alpha",
|
"version": "0.0.8-alpha",
|
||||||
"description": "SnatchGame client UI server",
|
"description": "SnatchGame client UI server",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -28,6 +28,26 @@ const onJoinGame = (client: any) => {
|
|||||||
gameClient.value = client
|
gameClient.value = client
|
||||||
currentScreen.value = 'game'
|
currentScreen.value = 'game'
|
||||||
logger.info('Transitioning to game screen')
|
logger.info('Transitioning to game screen')
|
||||||
|
|
||||||
|
// Handle admin kick notification
|
||||||
|
client.onAdminKicked((data: any) => {
|
||||||
|
// Show alert message
|
||||||
|
alert(`🚫 ${data.message}`)
|
||||||
|
|
||||||
|
// Return to home screen
|
||||||
|
currentScreen.value = 'home'
|
||||||
|
gameClient.value = null
|
||||||
|
|
||||||
|
logger.info('Player kicked by admin, returned to home screen')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle round change notification
|
||||||
|
client.onRoundChanged((data: any) => {
|
||||||
|
// Show alert message
|
||||||
|
alert(`🎯 ${data.message}`)
|
||||||
|
|
||||||
|
logger.info('Round changed by admin:', data)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export class GameClient {
|
|||||||
// Event callbacks
|
// Event callbacks
|
||||||
private onStateChangeCallbacks: ((state: GameState) => void)[] = []
|
private onStateChangeCallbacks: ((state: GameState) => void)[] = []
|
||||||
private onGamePhaseChangeCallbacks: ((phase: string) => void)[] = []
|
private onGamePhaseChangeCallbacks: ((phase: string) => void)[] = []
|
||||||
|
private onAdminKickedCallbacks: ((data: any) => void)[] = []
|
||||||
|
private onRoundChangedCallbacks: ((data: any) => void)[] = []
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'ws://localhost:2567'
|
const serverUrl = import.meta.env.VITE_SERVER_URL || 'ws://localhost:2567'
|
||||||
@@ -61,12 +63,38 @@ export class GameClient {
|
|||||||
this.room.onLeave((code) => {
|
this.room.onLeave((code) => {
|
||||||
logger.info('Left room with code:', code)
|
logger.info('Left room with code:', code)
|
||||||
this.isConnected = false
|
this.isConnected = false
|
||||||
|
|
||||||
|
// Handle forced disconnect by admin
|
||||||
|
if (code === 4000) {
|
||||||
|
logger.info('Disconnected by admin (code 4000)')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.room.onError((code, message) => {
|
this.room.onError((code, message) => {
|
||||||
logger.error('Room error:', { code, message })
|
logger.error('Room error:', { code, message })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle admin kick message
|
||||||
|
this.room.onMessage("adminKicked", (data) => {
|
||||||
|
logger.info('Received admin kick message:', data)
|
||||||
|
this.onAdminKickedCallbacks.forEach(callback => callback(data))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle game pause/resume messages
|
||||||
|
this.room.onMessage("gamePaused", (data) => {
|
||||||
|
logger.info('Game paused by admin:', data)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.room.onMessage("gameResumed", (data) => {
|
||||||
|
logger.info('Game resumed by admin:', data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle round change messages
|
||||||
|
this.room.onMessage("roundChanged", (data) => {
|
||||||
|
logger.info('Round changed by admin:', data)
|
||||||
|
this.onRoundChangedCallbacks.forEach(callback => callback(data))
|
||||||
|
})
|
||||||
|
|
||||||
return this.room
|
return this.room
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to join room:', error)
|
logger.error('Failed to join room:', error)
|
||||||
@@ -122,6 +150,30 @@ export class GameClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onAdminKicked(callback: (data: any) => void): () => void {
|
||||||
|
this.onAdminKickedCallbacks.push(callback)
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
const index = this.onAdminKickedCallbacks.indexOf(callback)
|
||||||
|
if (index > -1) {
|
||||||
|
this.onAdminKickedCallbacks.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRoundChanged(callback: (data: any) => void): () => void {
|
||||||
|
this.onRoundChangedCallbacks.push(callback)
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
const index = this.onRoundChangedCallbacks.indexOf(callback)
|
||||||
|
if (index > -1) {
|
||||||
|
this.onRoundChangedCallbacks.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Game actions
|
// Game actions
|
||||||
sendClick(): void {
|
sendClick(): void {
|
||||||
if (this.room && this.gameState?.gamePhase === 'playing') {
|
if (this.room && this.gameState?.gamePhase === 'playing') {
|
||||||
|
|||||||
2015
package-lock.json
generated
2015
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "snatchgame",
|
"name": "snatchgame",
|
||||||
"version": "0.0.1-alpha",
|
"version": "0.0.8-alpha",
|
||||||
"description": "Multiplayer real-time click battle game built with Colyseus.io and Vue 3",
|
"description": "Multiplayer real-time click battle game built with Colyseus.io and Vue 3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"data":{"colyseus:nodes":[],"l:game:gameMode:classic":["iys_3ih45","wnh3Dc813","x_N2KoM0r","ZYjw9gCJY"]},"hash":{"roomcount":{},"roomhistory":{"iys_3ih45":"{\"clientOptions\":{\"playerName\":\"Jugador Test\",\"gameMode\":\"classic\"},\"roomName\":\"game\",\"processId\":\"xX4LpKzp8\"}"},"ch:game":{"gameMode:classic":"0"}},"keys":{}}
|
{"data":{"colyseus:nodes":[],"l:game:gameMode:classic":["orqtACqT1","CXsliCwrI","C9VtR3g7Y","5NLMNLp3K","E4GQ95vBo","B8gUy5Ru2","EsWHo9NKW","FfK7pxOt6","clgTgsAZH","O58m3Nc96","07HzAIKHi","-MIdmG17D","SYCmeWB4a","bH9pgtu9q","wOWYHPF2g","lT5vitAWz","DxP0OlLZy","HxsfS-rHq","xoXuKLe8y","Pdth85aBY","OIfp4Z9v1","1stjMQLZG","-oVixmPUu","mGyUM4X_N","Dr2CbrciW","7l-3_W2E6","stIgKYzbR","iys_3ih45","wnh3Dc813","x_N2KoM0r","ZYjw9gCJY"]},"hash":{"roomcount":{},"roomhistory":{},"ch:game":{"gameMode:classic":"0"}},"keys":{}}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "snatchgame-server",
|
"name": "snatchgame-server",
|
||||||
"version": "0.0.5-alpha",
|
"version": "0.0.8-alpha",
|
||||||
"description": "SnatchGame multiplayer server using Colyseus",
|
"description": "SnatchGame multiplayer server using Colyseus",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@colyseus/core": "^0.16.19",
|
||||||
|
"@colyseus/monitor": "^0.16.7",
|
||||||
"@colyseus/schema": "^3.0.42",
|
"@colyseus/schema": "^3.0.42",
|
||||||
"@colyseus/tools": "^0.16.0",
|
"@colyseus/tools": "^0.16.0",
|
||||||
"colyseus": "^0.16.0",
|
"colyseus": "^0.16.0",
|
||||||
@@ -28,6 +30,6 @@
|
|||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import config from "@colyseus/tools";
|
import config from "@colyseus/tools";
|
||||||
|
import { monitor } from "@colyseus/monitor";
|
||||||
|
import { matchMaker } from "@colyseus/core";
|
||||||
import { GameRoom } from "./rooms/GameRoom";
|
import { GameRoom } from "./rooms/GameRoom";
|
||||||
|
|
||||||
export default config({
|
export default config({
|
||||||
@@ -19,12 +21,380 @@ export default config({
|
|||||||
res.json({
|
res.json({
|
||||||
name: "SnatchGame Server",
|
name: "SnatchGame Server",
|
||||||
status: "running",
|
status: "running",
|
||||||
version: "1.0.0"
|
version: "0.0.8-alpha",
|
||||||
|
description: "Multiplayer game server for SnatchGame",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
res.json({ status: "healthy" });
|
res.json({ status: "healthy" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Colyseus official monitoring panel
|
||||||
|
app.use("/monitor", monitor({
|
||||||
|
columns: [
|
||||||
|
'roomId',
|
||||||
|
'name',
|
||||||
|
'clients',
|
||||||
|
'maxClients',
|
||||||
|
'locked',
|
||||||
|
'elapsedTime'
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CORS for admin interface
|
||||||
|
app.use("/api/admin", (req, res, next) => {
|
||||||
|
res.header("Access-Control-Allow-Origin", "*");
|
||||||
|
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||||
|
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.sendStatus(200);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
app.use(require('express').json());
|
||||||
|
|
||||||
|
// Get game statistics using official matchMaker API
|
||||||
|
app.get("/api/admin/stats", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rooms = await matchMaker.query({});
|
||||||
|
let totalPlayers = 0;
|
||||||
|
let activeGames = 0;
|
||||||
|
const gameRooms = [];
|
||||||
|
const allPlayers = [];
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.name === 'game') {
|
||||||
|
activeGames++;
|
||||||
|
totalPlayers += room.clients;
|
||||||
|
|
||||||
|
// Get detailed room information including players
|
||||||
|
try {
|
||||||
|
const roomData = await matchMaker.remoteRoomCall(room.roomId, "getInspectData");
|
||||||
|
|
||||||
|
gameRooms.push({
|
||||||
|
roomId: room.roomId,
|
||||||
|
players: room.clients,
|
||||||
|
maxPlayers: room.maxClients,
|
||||||
|
locked: room.locked,
|
||||||
|
createdAt: room.createdAt,
|
||||||
|
elapsedTime: Date.now() - new Date(room.createdAt).getTime()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract player information with their tokens
|
||||||
|
if (roomData.state && roomData.state.players) {
|
||||||
|
// Use forEach to iterate over MapSchema properly
|
||||||
|
roomData.state.players.forEach((player, playerId) => {
|
||||||
|
// Skip internal MapSchema properties
|
||||||
|
if (playerId.startsWith('$') || playerId === 'deletedItems') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allPlayers.push({
|
||||||
|
id: playerId,
|
||||||
|
name: player.name || 'Unknown',
|
||||||
|
roomId: room.roomId,
|
||||||
|
role: player.role || 'player',
|
||||||
|
producerRole: player.producerRole || 'turkey',
|
||||||
|
tokens: {
|
||||||
|
turkeys: player.tokens?.turkey || 0,
|
||||||
|
coffee: player.tokens?.coffee || 0,
|
||||||
|
corn: player.tokens?.corn || 0
|
||||||
|
},
|
||||||
|
isReady: player.isReady || false,
|
||||||
|
isConnected: player.isConnected !== false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (roomError) {
|
||||||
|
console.error(`Failed to get room data for ${room.roomId}:`, roomError);
|
||||||
|
// Still add basic room info even if detailed data fails
|
||||||
|
gameRooms.push({
|
||||||
|
roomId: room.roomId,
|
||||||
|
players: room.clients,
|
||||||
|
maxPlayers: room.maxClients,
|
||||||
|
locked: room.locked,
|
||||||
|
createdAt: room.createdAt,
|
||||||
|
elapsedTime: Date.now() - new Date(room.createdAt).getTime()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
connectedPlayers: totalPlayers,
|
||||||
|
activeGames,
|
||||||
|
currentRound: activeGames > 0 ? 'Ronda 1' : 'waiting',
|
||||||
|
gameState: activeGames > 0 ? 'in_progress' : 'waiting_for_players',
|
||||||
|
players: allPlayers,
|
||||||
|
rooms: gameRooms
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin stats error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to get stats',
|
||||||
|
connectedPlayers: 0,
|
||||||
|
activeGames: 0,
|
||||||
|
currentRound: 'error',
|
||||||
|
gameState: 'error',
|
||||||
|
players: [],
|
||||||
|
rooms: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kick player using official matchMaker API
|
||||||
|
app.post("/api/admin/kick-player", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { playerId } = req.body;
|
||||||
|
|
||||||
|
if (!playerId) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Player ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all rooms to find the player
|
||||||
|
const rooms = await matchMaker.query({});
|
||||||
|
let playerFound = false;
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.name === 'game') {
|
||||||
|
try {
|
||||||
|
// Try to kick the player from this room
|
||||||
|
await matchMaker.remoteRoomCall(room.roomId, "_forceClientDisconnect", [playerId]);
|
||||||
|
console.log(`🚫 Admin kicked player ${playerId} from room ${room.roomId}`);
|
||||||
|
playerFound = true;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
// Player not in this room, continue searching
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerFound) {
|
||||||
|
res.json({ success: true, message: `Player ${playerId} kicked` });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ success: false, message: 'Player not found' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kick player error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to kick player' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pause game using official matchMaker API
|
||||||
|
app.post("/api/admin/pause-game", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rooms = await matchMaker.query({});
|
||||||
|
let pausedGames = 0;
|
||||||
|
|
||||||
|
// Pause all active games
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.name === 'game') {
|
||||||
|
try {
|
||||||
|
await matchMaker.remoteRoomCall(room.roomId, "pauseGame");
|
||||||
|
pausedGames++;
|
||||||
|
console.log(`⏸️ Admin paused game in room ${room.roomId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to pause game in room ${room.roomId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${pausedGames} games paused`,
|
||||||
|
pausedGames
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Pause game error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to pause games' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resume game using official matchMaker API
|
||||||
|
app.post("/api/admin/resume-game", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rooms = await matchMaker.query({});
|
||||||
|
let resumedGames = 0;
|
||||||
|
|
||||||
|
// Resume all paused games
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.name === 'game') {
|
||||||
|
try {
|
||||||
|
await matchMaker.remoteRoomCall(room.roomId, "resumeGame");
|
||||||
|
resumedGames++;
|
||||||
|
console.log(`▶️ Admin resumed game in room ${room.roomId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to resume game in room ${room.roomId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${resumedGames} games resumed`,
|
||||||
|
resumedGames
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Resume game error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to resume games' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel game using official matchMaker API
|
||||||
|
app.post("/api/admin/cancel-game", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { gameId } = req.body;
|
||||||
|
|
||||||
|
if (gameId) {
|
||||||
|
// Cancel specific game
|
||||||
|
try {
|
||||||
|
await matchMaker.remoteRoomCall(gameId, "disconnect");
|
||||||
|
console.log(`❌ Admin cancelled game ${gameId}`);
|
||||||
|
res.json({ success: true, message: `Game ${gameId} cancelled` });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to cancel game ${gameId}:`, error);
|
||||||
|
res.status(404).json({ success: false, message: 'Game not found' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cancel all games
|
||||||
|
const rooms = await matchMaker.query({});
|
||||||
|
let cancelledGames = 0;
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.name === 'game') {
|
||||||
|
try {
|
||||||
|
await matchMaker.remoteRoomCall(room.roomId, "disconnect");
|
||||||
|
cancelledGames++;
|
||||||
|
console.log(`❌ Admin cancelled game ${room.roomId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to cancel game ${room.roomId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${cancelledGames} games cancelled`,
|
||||||
|
cancelledGames
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cancel game error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to cancel games' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kick all players - empty all rooms
|
||||||
|
app.post("/api/admin/kick-all-players", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rooms = await matchMaker.query({});
|
||||||
|
let kickedPlayers = 0;
|
||||||
|
let roomsCleared = 0;
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.name === 'game') {
|
||||||
|
try {
|
||||||
|
// Get room data to get player information
|
||||||
|
const roomData = await matchMaker.remoteRoomCall(room.roomId, "getInspectData");
|
||||||
|
const playerCount = roomData.clients?.length || 0;
|
||||||
|
|
||||||
|
if (playerCount > 0) {
|
||||||
|
// Kick each player individually to send proper notifications
|
||||||
|
await matchMaker.remoteRoomCall(room.roomId, "_forceDisconnectAllClients");
|
||||||
|
kickedPlayers += playerCount;
|
||||||
|
roomsCleared++;
|
||||||
|
console.log(`🚫🚫 Admin cleared room ${room.roomId} - ${playerCount} players kicked`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to clear room ${room.roomId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${kickedPlayers} jugadores expulsados de ${roomsCleared} salas`,
|
||||||
|
kickedPlayers,
|
||||||
|
roomsCleared
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kick all players error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to kick all players' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance round globally - all active games
|
||||||
|
app.post("/api/admin/advance-round", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rooms = await matchMaker.query({});
|
||||||
|
let roundsAdvanced = 0;
|
||||||
|
let newRound = 1;
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.name === 'game') {
|
||||||
|
try {
|
||||||
|
const result = await matchMaker.remoteRoomCall(room.roomId, "advanceRound");
|
||||||
|
if (result && result.success) {
|
||||||
|
roundsAdvanced++;
|
||||||
|
newRound = result.newRound;
|
||||||
|
console.log(`⏭️ Admin advanced round in ${room.roomId} to round ${newRound}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to advance round in room ${room.roomId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Ronda avanzada a ${newRound} en ${roundsAdvanced} salas`,
|
||||||
|
roundsAdvanced,
|
||||||
|
newRound
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Advance round error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to advance round' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Go back round globally - all active games
|
||||||
|
app.post("/api/admin/previous-round", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rooms = await matchMaker.query({});
|
||||||
|
let roundsChanged = 0;
|
||||||
|
let newRound = 1;
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (room.name === 'game') {
|
||||||
|
try {
|
||||||
|
const result = await matchMaker.remoteRoomCall(room.roomId, "previousRound");
|
||||||
|
if (result && result.success) {
|
||||||
|
roundsChanged++;
|
||||||
|
newRound = result.newRound;
|
||||||
|
console.log(`⏮️ Admin went back round in ${room.roomId} to round ${newRound}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to go back round in room ${room.roomId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Ronda retrocedida a ${newRound} en ${roundsChanged} salas`,
|
||||||
|
roundsChanged,
|
||||||
|
newRound
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Previous round error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Failed to go back round' });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -268,4 +268,148 @@ export class GameRoom extends Room<GameState> {
|
|||||||
onDispose() {
|
onDispose() {
|
||||||
console.log(`GameRoom ${this.roomId} disposed`);
|
console.log(`GameRoom ${this.roomId} disposed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method for admin monitoring - used by Colyseus monitor and admin API
|
||||||
|
getInspectData() {
|
||||||
|
const stateSize = JSON.stringify(this.state).length;
|
||||||
|
const roomElapsedTime = this.clock.elapsedTime;
|
||||||
|
|
||||||
|
// Gather client information
|
||||||
|
const clients = this.clients.map((client) => ({
|
||||||
|
sessionId: client.sessionId,
|
||||||
|
elapsedTime: roomElapsedTime - (client as any)._joinedAt || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Return comprehensive room data
|
||||||
|
return {
|
||||||
|
roomId: this.roomId,
|
||||||
|
name: 'game',
|
||||||
|
clients: clients.length,
|
||||||
|
maxClients: this.maxClients,
|
||||||
|
locked: this.locked,
|
||||||
|
state: this.state,
|
||||||
|
stateSize,
|
||||||
|
clients: clients,
|
||||||
|
elapsedTime: roomElapsedTime,
|
||||||
|
metadata: {
|
||||||
|
gamePhase: this.state.gamePhase,
|
||||||
|
gameStarted: this.state.gameStarted,
|
||||||
|
round: this.state.round,
|
||||||
|
playerCount: this.state.players.size,
|
||||||
|
activeOffers: this.state.activeTradeOffers.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin methods for game control
|
||||||
|
pauseGame() {
|
||||||
|
if (this.state.gameStarted && this.state.gamePhase !== 'paused') {
|
||||||
|
this.state.gamePhase = 'paused';
|
||||||
|
console.log(`⏸️ Game paused in room ${this.roomId} by admin`);
|
||||||
|
|
||||||
|
// Broadcast pause message to all clients
|
||||||
|
this.broadcast("gamePaused", {
|
||||||
|
message: "El juego ha sido pausado por el administrador",
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeGame() {
|
||||||
|
if (this.state.gameStarted && this.state.gamePhase === 'paused') {
|
||||||
|
this.state.gamePhase = 'trading'; // Resume to trading phase
|
||||||
|
console.log(`▶️ Game resumed in room ${this.roomId} by admin`);
|
||||||
|
|
||||||
|
// Broadcast resume message to all clients
|
||||||
|
this.broadcast("gameResumed", {
|
||||||
|
message: "El juego ha sido reanudado por el administrador",
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_forceClientDisconnect(sessionId: string) {
|
||||||
|
const client = this.clients.find(c => c.sessionId === sessionId);
|
||||||
|
if (client) {
|
||||||
|
console.log(`🚫 Admin force disconnect player ${sessionId} from room ${this.roomId}`);
|
||||||
|
|
||||||
|
// Send notification to the specific client before disconnecting
|
||||||
|
client.send("adminKicked", {
|
||||||
|
message: "Has sido expulsado del juego por el administrador",
|
||||||
|
reason: "admin_kick",
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give client time to process the message, then disconnect
|
||||||
|
setTimeout(() => {
|
||||||
|
client.leave(4000); // Force disconnect with code 4000
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Player ${sessionId} not found in room ${this.roomId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_forceDisconnectAllClients() {
|
||||||
|
console.log(`🚫🚫 Admin force disconnect ALL players from room ${this.roomId}`);
|
||||||
|
|
||||||
|
if (this.clients.length === 0) {
|
||||||
|
return { success: true, kickedPlayers: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification to all clients first
|
||||||
|
this.broadcast("adminKicked", {
|
||||||
|
message: "Todos los jugadores han sido expulsados por el administrador",
|
||||||
|
reason: "admin_kick_all",
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
const kickedCount = this.clients.length;
|
||||||
|
|
||||||
|
// Give clients time to process the message, then disconnect all
|
||||||
|
setTimeout(() => {
|
||||||
|
// Create a copy of clients array since it will be modified during iteration
|
||||||
|
const clientsToDisconnect = [...this.clients];
|
||||||
|
clientsToDisconnect.forEach(client => {
|
||||||
|
client.leave(4000); // Force disconnect with code 4000
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return { success: true, kickedPlayers: kickedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
advanceRound() {
|
||||||
|
const oldRound = this.state.round;
|
||||||
|
this.state.round = Math.min(oldRound + 1, 10); // Max 10 rounds
|
||||||
|
const newRound = this.state.round;
|
||||||
|
|
||||||
|
console.log(`⏭️ Round advanced from ${oldRound} to ${newRound} in room ${this.roomId}`);
|
||||||
|
|
||||||
|
// Broadcast round change to all clients
|
||||||
|
this.broadcast("roundChanged", {
|
||||||
|
oldRound,
|
||||||
|
newRound,
|
||||||
|
message: `Ronda ${newRound} - Cambio realizado por el administrador`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, newRound, oldRound };
|
||||||
|
}
|
||||||
|
|
||||||
|
previousRound() {
|
||||||
|
const oldRound = this.state.round;
|
||||||
|
this.state.round = Math.max(oldRound - 1, 1); // Min round 1
|
||||||
|
const newRound = this.state.round;
|
||||||
|
|
||||||
|
console.log(`⏮️ Round went back from ${oldRound} to ${newRound} in room ${this.roomId}`);
|
||||||
|
|
||||||
|
// Broadcast round change to all clients
|
||||||
|
this.broadcast("roundChanged", {
|
||||||
|
oldRound,
|
||||||
|
newRound,
|
||||||
|
message: `Ronda ${newRound} - Cambio realizado por el administrador`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, newRound, oldRound };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user