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:
2025-07-04 17:43:28 -06:00
parent 656cf7988e
commit eb6d19906b
26 changed files with 6123 additions and 1000 deletions

358
admin/README.md Normal file
View File

@@ -0,0 +1,358 @@
# 🎛️ SnatchGame Admin Dashboard
[![Version](https://img.shields.io/badge/version-0.0.8--alpha-orange.svg)](https://github.com/username/snatchgame)
[![Vue.js](https://img.shields.io/badge/vue-3.0+-brightgreen.svg)](https://vuejs.org/)
[![TypeScript](https://img.shields.io/badge/typescript-5.0+-blue.svg)](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
View 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

File diff suppressed because it is too large Load Diff

39
admin/package.json Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View 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()

View 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;
}

View 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
View 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
View 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
View 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'
}
}
})