331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import express from 'express';
|
|
import http from 'http';
|
|
import { Server } from 'socket.io';
|
|
import cors from 'cors';
|
|
import dotenv from 'dotenv';
|
|
import crypto from 'crypto';
|
|
import { Game } from './models/Game';
|
|
import { GamePhase } from '../../shared/types';
|
|
|
|
dotenv.config();
|
|
|
|
const app = express();
|
|
const port = process.env.PORT || 4000;
|
|
|
|
app.use(cors({
|
|
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
|
methods: ["GET", "POST"]
|
|
}));
|
|
|
|
const server = http.createServer(app);
|
|
const io = new Server(server, {
|
|
cors: {
|
|
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
|
methods: ["GET", "POST"]
|
|
}
|
|
});
|
|
|
|
// ALMACÉN DE PARTIDAS (En memoria por ahora)
|
|
// En el futuro esto podría estar en Redis o Postgres
|
|
const games: Record<string, Game> = {};
|
|
|
|
// Almacén de timers para auto-resolución de votaciones
|
|
const voteTimers: Record<string, NodeJS.Timeout> = {};
|
|
|
|
// --- LOBBY MANAGEMENT ---
|
|
|
|
const MISSION_NAMES = [
|
|
"Operación Overlord", "Operación Market Garden", "Operación Barbarroja",
|
|
"Operación Valkiria", "Operación Torch", "Operación Husky",
|
|
"Batalla de Stalingrado", "Dunkerque", "El Alamein", "Midway",
|
|
"Operación Ciudadela", "Operación Dragoon", "Operación Fortaleza",
|
|
"Operación Eiche", "Operación León Marino", "Operación Urano"
|
|
];
|
|
|
|
// Helper para iniciar timer de votación de líder (10 segundos)
|
|
function startLeaderVoteTimer(roomId: string) {
|
|
if (voteTimers[roomId]) clearTimeout(voteTimers[roomId]);
|
|
voteTimers[roomId] = setTimeout(() => {
|
|
const g = games[roomId];
|
|
if (g && g.state.phase === 'vote_leader') {
|
|
// Forzar resolución de votos cuando se acaba el tiempo
|
|
g.forceResolveLeaderVote();
|
|
io.to(roomId).emit('game_state', g.state);
|
|
|
|
// Si sigue en vote_leader (líder rechazado, nuevo líder), reiniciar timer
|
|
if (g.state.phase === 'vote_leader') {
|
|
startLeaderVoteTimer(roomId);
|
|
}
|
|
}
|
|
}, 10000); // 10 segundos
|
|
}
|
|
|
|
const generateRoomName = () => {
|
|
const idx = Math.floor(Math.random() * MISSION_NAMES.length);
|
|
const suffix = Math.floor(100 + Math.random() * 900); // 3 digit code
|
|
return `${MISSION_NAMES[idx]} #${suffix}`;
|
|
};
|
|
|
|
io.on('connection', (socket) => {
|
|
console.log('Cliente conectado:', socket.id);
|
|
|
|
// Enviar lista de salas al conectar
|
|
socket.emit('rooms_list', getRoomsList());
|
|
|
|
// A. CREAR SALA
|
|
socket.on('create_game', ({ hostName, maxPlayers, password }) => {
|
|
console.log(`[CREATE_GAME] Host: ${hostName}, Players: ${maxPlayers}`);
|
|
const roomId = crypto.randomUUID();
|
|
const roomName = generateRoomName();
|
|
|
|
const newGame = new Game(roomId, roomName, socket.id, maxPlayers, password);
|
|
games[roomId] = newGame;
|
|
|
|
// Unir al creador automáticamente
|
|
newGame.addPlayer(socket.id, hostName);
|
|
socket.join(roomId);
|
|
|
|
// Notificar al creador
|
|
socket.emit('game_joined', { roomId, state: newGame.state });
|
|
|
|
// Actualizar lista a todos
|
|
io.emit('rooms_list', getRoomsList());
|
|
});
|
|
|
|
// B. UNIRSE A SALA
|
|
socket.on('join_game', ({ roomId, playerName, password }) => {
|
|
const game = games[roomId];
|
|
|
|
if (!game) {
|
|
socket.emit('error', 'La partida no existe');
|
|
return;
|
|
}
|
|
|
|
if (game.password && game.password !== password) {
|
|
socket.emit('error', 'Contraseña incorrecta');
|
|
return;
|
|
}
|
|
|
|
if (game.state.players.length >= game.maxPlayers) {
|
|
socket.emit('error', 'La partida está llena');
|
|
return;
|
|
}
|
|
|
|
// Evitar duplicados
|
|
const existingPlayer = game.state.players.find(p => p.id === socket.id);
|
|
if (!existingPlayer) {
|
|
game.addPlayer(socket.id, playerName);
|
|
}
|
|
|
|
socket.join(roomId);
|
|
|
|
// Enviar estado actualizado a la sala
|
|
io.to(roomId).emit('game_state', game.state);
|
|
// Avisar al usuario que entró OK
|
|
socket.emit('game_joined', { roomId, state: game.state });
|
|
|
|
// Actualizar lista de salas (cambió contador de jugadores)
|
|
io.emit('rooms_list', getRoomsList());
|
|
});
|
|
|
|
// C. REFRESCAR LISTA
|
|
socket.on('get_rooms', () => {
|
|
socket.emit('rooms_list', getRoomsList());
|
|
});
|
|
|
|
// --- GAMEPLAY DE SIEMPRE (Adaptado a roomId dinámico) ---
|
|
|
|
// 2. INICIAR PARTIDA - FASE INTRO
|
|
socket.on('start_game', ({ roomId }) => {
|
|
const game = games[roomId];
|
|
// Solo el host puede iniciar
|
|
if (game && game.hostId === socket.id && game.state.phase === 'lobby') {
|
|
game.state.phase = 'intro' as any;
|
|
io.to(roomId).emit('game_state', game.state);
|
|
io.emit('rooms_list', getRoomsList());
|
|
}
|
|
});
|
|
|
|
// 2.1 PASAR INTRO -> REVEAL ROLE
|
|
socket.on('finish_intro', ({ roomId }) => {
|
|
const game = games[roomId];
|
|
if (game && game.hostId === socket.id && game.state.phase === 'intro') {
|
|
// AQUI DISTRIBUIMOS ROLES
|
|
if (game.startGame()) {
|
|
// startGame pone phase en TEAM_BUILDING, pero nosotros queremos REVEAL_ROLE primero
|
|
game.state.phase = 'reveal_role' as any;
|
|
io.to(roomId).emit('game_state', game.state);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 2.2 REVEAL ROLE -> ROLL CALL
|
|
socket.on('finish_reveal', ({ roomId }) => {
|
|
const game = games[roomId];
|
|
// Cualquiera puede llamar a esto si es auto-timer, o solo host?
|
|
// El usuario dijo "Habrá una cuenta atrás de 5 segundos".
|
|
// Lo ideal es que el Host controle el tiempo para sincronizar.
|
|
if (game && game.hostId === socket.id && game.state.phase === 'reveal_role') {
|
|
game.state.phase = 'roll_call' as any;
|
|
io.to(roomId).emit('game_state', game.state);
|
|
}
|
|
});
|
|
|
|
// 2.3 FINALIZAR ROLL CALL -> PRIMER TURNO DE JUEGO
|
|
socket.on('finish_roll_call', ({ roomId }) => {
|
|
const game = games[roomId];
|
|
if (game && game.hostId === socket.id && game.state.phase === 'roll_call') {
|
|
// ERROR CORREGIDO: No llamar a startGame() de nuevo porque re-baraja los roles.
|
|
// Simplemente avanzamos a la fase de votación de líder que ya estaba configurada.
|
|
game.state.phase = 'vote_leader' as any;
|
|
|
|
// Iniciar timer de 11 segundos para forzar cambio de líder
|
|
startLeaderVoteTimer(roomId);
|
|
|
|
io.to(roomId).emit('game_state', game.state);
|
|
}
|
|
});
|
|
|
|
|
|
// 2.4 VOTAR LÍDER
|
|
socket.on('vote_leader', ({ roomId, approve }) => {
|
|
const game = games[roomId];
|
|
if (game) {
|
|
const previousPhase = game.state.phase;
|
|
game.voteLeader(socket.id, approve);
|
|
io.to(roomId).emit('game_state', game.state);
|
|
|
|
// Si cambió de fase (líder aprobado o rechazado)
|
|
if (game.state.phase !== previousPhase) {
|
|
// Limpiar timer actual
|
|
if (voteTimers[roomId]) {
|
|
clearTimeout(voteTimers[roomId]);
|
|
delete voteTimers[roomId];
|
|
}
|
|
|
|
// Si pasó a vote_leader de nuevo (líder rechazado), iniciar nuevo timer
|
|
if (game.state.phase === 'vote_leader') {
|
|
startLeaderVoteTimer(roomId);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 3. PROPONER EQUIPO
|
|
socket.on('propose_team', ({ roomId, teamIds }) => {
|
|
const game = games[roomId];
|
|
if (game && game.proposeTeam(teamIds)) {
|
|
io.to(roomId).emit('game_state', game.state);
|
|
}
|
|
});
|
|
|
|
// 4. VOTAR EQUIPO
|
|
socket.on('vote_team', ({ roomId, approve }) => {
|
|
const game = games[roomId];
|
|
if (game) {
|
|
game.voteForTeam(socket.id, approve);
|
|
io.to(roomId).emit('game_state', game.state);
|
|
}
|
|
});
|
|
|
|
|
|
// 5. VOTAR MISIÓN
|
|
socket.on('vote_mission', ({ roomId, success }) => {
|
|
const game = games[roomId];
|
|
if (game) {
|
|
game.voteMission(success);
|
|
io.to(roomId).emit('game_state', game.state);
|
|
}
|
|
});
|
|
|
|
// 5.1 FINALIZAR REVELACIÓN DE CARTAS
|
|
socket.on('finish_reveal', ({ roomId }) => {
|
|
const game = games[roomId];
|
|
// Permitir a cualquiera avanzar para evitar bloqueos
|
|
if (game && game.state.phase === 'mission_reveal') {
|
|
game.finishReveal();
|
|
io.to(roomId).emit('game_state', game.state);
|
|
}
|
|
});
|
|
|
|
|
|
// 5.2 FINALIZAR PANTALLA DE RESULTADO
|
|
socket.on('finish_mission_result', ({ roomId }) => {
|
|
const game = games[roomId];
|
|
if (game && game.hostId === socket.id && game.state.phase === GamePhase.MISSION_RESULT) {
|
|
game.finishMissionResult();
|
|
io.to(roomId).emit('game_state', game.state);
|
|
|
|
// Si volvió a vote_leader (nueva ronda), iniciar timer
|
|
// TypeScript no detecta que finishMissionResult() cambia la fase, usamos type assertion
|
|
if ((game.state.phase as GamePhase) === GamePhase.VOTE_LEADER) {
|
|
startLeaderVoteTimer(roomId);
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
// 6. ASESINATO FINAL
|
|
socket.on('assassin_kill', ({ roomId, targetId }) => {
|
|
const game = games[roomId];
|
|
if (game) {
|
|
game.assassinKill(targetId);
|
|
io.to(roomId).emit('game_state', game.state);
|
|
}
|
|
});
|
|
|
|
// 7. REINICIAR PARTIDA
|
|
socket.on('restart_game', ({ roomId }) => {
|
|
const game = games[roomId];
|
|
if (game && game.hostId === socket.id) {
|
|
game.restartGame();
|
|
io.to(roomId).emit('game_state', game.state);
|
|
}
|
|
});
|
|
|
|
// 8. FINALIZAR Y EXPULSAR JUGADORES
|
|
socket.on('finalize_game', ({ roomId }) => {
|
|
const game = games[roomId];
|
|
if (game && game.hostId === socket.id) {
|
|
// Notificar a todos los jugadores que la partida ha sido finalizada
|
|
io.to(roomId).emit('game_finalized');
|
|
|
|
// Eliminar la partida inmediatamente del registro
|
|
delete games[roomId];
|
|
|
|
// Actualizar lista de salas para todos los clientes
|
|
io.emit('rooms_list', getRoomsList());
|
|
|
|
// Desconectar a todos los jugadores de la sala
|
|
io.in(roomId).socketsLeave(roomId);
|
|
}
|
|
});
|
|
|
|
// 9. DESCONEXIÓN
|
|
socket.on('disconnect', () => {
|
|
// Buscar en qué partida estaba y sacarlo (opcional, por ahora solo notificamos)
|
|
console.log('Desconectado:', socket.id);
|
|
// TODO: Eliminar de la partida si está en LOBBY para liberar hueco
|
|
});
|
|
});
|
|
|
|
const getRoomsList = () => {
|
|
return Object.values(games).map(g => ({
|
|
id: g.state.roomId,
|
|
name: g.roomName,
|
|
hostId: g.hostId,
|
|
currentPlayers: g.state.players.length,
|
|
maxPlayers: g.maxPlayers,
|
|
isPrivate: !!g.password,
|
|
status: g.state.phase === 'lobby' ? 'waiting' : 'playing'
|
|
}));
|
|
};
|
|
|
|
app.get('/', (req, res) => {
|
|
res.send('Servidor de La Resistencia funcionando 🚀');
|
|
});
|
|
|
|
server.listen(port, () => {
|
|
console.log(`[server]: Servidor corriendo en http://localhost:${port}`);
|
|
});
|