feat: Implementar Dashboard de Administración y Historial de Partidas

- Crear Dashboard en /dashboard con protección por contraseña
- Integrar PostgreSQL para registro histórico de partidas (game_logs)
- Permitir forzar cierre de partidas y expulsar jugadores desde Dashboard
- Diseño premium e integración en tiempo real vía Sockets
This commit is contained in:
Resistencia Dev
2025-12-22 18:01:48 +01:00
parent 848eb0486d
commit 3d68eddb8b
5 changed files with 2399 additions and 2 deletions

1960
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,11 @@
"author": "",
"license": "ISC",
"dependencies": {
"@types/pg": "^8.16.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"pg": "^8.16.3",
"socket.io": "^4.7.2"
},
"devDependencies": {
@@ -25,4 +27,4 @@
"ts-node": "^10.9.1",
"typescript": "^5.3.2"
}
}
}

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

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

View File

@@ -6,9 +6,13 @@ import dotenv from 'dotenv';
import crypto from 'crypto';
import { Game } from './models/Game';
import { GamePhase } from '../../shared/types';
import { initDb, logGameStart, logGameEnd, updateGamePlayers, getGameHistory } from './db';
dotenv.config();
// Inicializar DB
initDb();
const app = express();
const port = process.env.PORT || 4000;
@@ -90,6 +94,9 @@ io.on('connection', (socket) => {
// Actualizar lista a todos
io.emit('rooms_list', getRoomsList());
// LOG EN DB
logGameStart(roomId, roomName, hostName, maxPlayers);
});
// B. UNIRSE A SALA
@@ -126,6 +133,9 @@ io.on('connection', (socket) => {
// Actualizar lista de salas (cambió contador de jugadores)
io.emit('rooms_list', getRoomsList());
// ACTUALIZAR LOG EN DB
updateGamePlayers(roomId, game.state.players.map(p => p.name));
});
// C. REFRESCAR LISTA
@@ -298,6 +308,9 @@ io.on('connection', (socket) => {
// Desconectar a todos los jugadores de la sala
io.in(roomId).socketsLeave(roomId);
// LOG EN DB
logGameEnd(roomId, game.state.winner, false);
}
});
@@ -328,6 +341,9 @@ io.on('connection', (socket) => {
// Desconectar a todos de la sala
io.in(roomId).socketsLeave(roomId);
// LOG EN DB COMO ABORTADA
logGameEnd(roomId, null, true);
console.log(`[LEAVE_GAME] ${playerName} abandonó la partida ${roomId}. Partida eliminada.`);
}
});
@@ -387,7 +403,71 @@ io.on('connection', (socket) => {
socket.on('disconnect', () => {
// Buscar en qué partida estaba y sacarlo (opcional, por ahora solo notificamos)
console.log('Desconectado:', socket.id);
// TODO: Eliminar de la partida si está en LOBBY para liberar hueco
});
// --- ADMIN COMMANDS ---
socket.on('admin_get_data', async () => {
const activeGamesData = Object.values(games).map(g => ({
id: g.state.roomId,
name: g.roomName,
status: g.state.phase,
currentPlayers: g.state.players.length,
maxPlayers: g.maxPlayers,
players: g.state.players.map(p => ({ id: p.id, name: p.name }))
}));
const history = await getGameHistory();
socket.emit('admin_data', {
activeGames: activeGamesData,
history: history
});
});
socket.on('admin_close_game', async ({ roomId }) => {
const game = games[roomId];
if (game) {
io.to(roomId).emit('game_finalized');
delete games[roomId];
if (voteTimers[roomId]) {
clearTimeout(voteTimers[roomId]);
delete voteTimers[roomId];
}
io.emit('rooms_list', getRoomsList());
io.in(roomId).socketsLeave(roomId);
// Log como abortada por admin
await logGameEnd(roomId, null, true);
socket.emit('admin_action_success');
}
});
socket.on('admin_kick_player', ({ roomId, targetSocketId }) => {
const game = games[roomId];
if (game) {
const playerIndex = game.state.players.findIndex(p => p.id === targetSocketId);
if (playerIndex !== -1) {
const playerName = game.state.players[playerIndex].name;
game.state.players.splice(playerIndex, 1);
// Notificar al jugador expulsado
io.to(targetSocketId).emit('game_finalized');
const targetSocket = io.sockets.sockets.get(targetSocketId);
targetSocket?.leave(roomId);
// Notificar al resto
io.to(roomId).emit('player_left_game', { playerName: `${playerName} (Expulsado)` });
io.to(roomId).emit('game_state', game.state);
io.emit('rooms_list', getRoomsList());
updateGamePlayers(roomId, game.state.players.map(p => p.name));
socket.emit('admin_action_success');
}
}
});
});