Files
FranciaOcupada/server/src/index.ts

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}`);
});