Estado actual con errores de sintaxis en GameBoard.tsx

This commit is contained in:
Resistencia Dev
2025-12-05 22:07:20 +01:00
commit 8d423ac19d
75 changed files with 2228 additions and 0 deletions

24
server/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:20-alpine
# Create app directory
WORKDIR /app
# Copy shared module first (as a sibling to server)
COPY shared ./shared
# Setup Server directory
WORKDIR /app/server
# Install dependencies
COPY server/package*.json ./
RUN npm install
# Copy server source code
COPY server/src ./src
COPY server/tsconfig.json ./
# Expose port
EXPOSE 4000
# Run with nodemon watching both src and shared
CMD ["npx", "nodemon", "--watch", "src", "--watch", "../shared", "--exec", "npx ts-node", "src/index.ts"]

28
server/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "resistencia-server",
"version": "1.0.0",
"description": "Backend para el juego La Resistencia",
"main": "src/index.ts",
"scripts": {
"dev": "npx nodemon --watch src --exec \"npx ts-node\" src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"socket.io": "^4.7.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"nodemon": "^3.0.1",
"ts-node": "^10.9.1",
"typescript": "^5.3.2"
}
}

254
server/src/index.ts Normal file
View File

@@ -0,0 +1,254 @@
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';
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> = {};
// --- 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"
];
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 (TEAM_BUILDING)
socket.on('finish_roll_call', ({ roomId }) => {
const game = games[roomId];
if (game && game.hostId === socket.id && game.state.phase === 'roll_call') {
// Ir a VOTE_LEADER (ya que startGame lo inicializa a VOTE_LEADER en el modelo, y nextLeader tambien)
// Solo debemos asegurarnos que el GameState se sincronice.
if (game.startGame()) {
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) {
game.voteLeader(socket.id, approve);
io.to(roomId).emit('game_state', game.state);
}
});
// 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];
if (game && game.hostId === socket.id && 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 === 'mission_result') {
game.finishMissionResult();
io.to(roomId).emit('game_state', game.state);
}
});
// 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. 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}`);
});

311
server/src/models/Game.ts Normal file
View File

@@ -0,0 +1,311 @@
import {
GameState,
Player,
GamePhase,
Role,
Faction,
GAME_CONFIG
} from '../../../shared/types';
export class Game {
public state: GameState;
public roomName: string;
public hostId: string;
public maxPlayers: number;
public password?: string;
constructor(
roomId: string,
roomName: string,
hostId: string,
maxPlayers: number,
password?: string
) {
this.roomName = roomName;
this.hostId = hostId;
this.maxPlayers = maxPlayers;
this.password = password;
this.state = {
roomId,
phase: GamePhase.LOBBY,
players: [],
currentRound: 1,
failedVotesCount: 0,
questResults: [null, null, null, null, null],
currentLeaderId: '',
leaderVotes: {},
proposedTeam: [],
teamVotes: {},
missionVotes: [],
missionHistory: [],
revealedVotes: [],
history: [],
hostId: hostId
};
}
addPlayer(id: string, name: string): Player {
// Asignar avatar aleatorio persistente (rebel001.jpg - rebel010.jpg)
const avatarIdx = Math.floor(Math.random() * 10) + 1;
const avatarStr = `rebel${avatarIdx.toString().padStart(3, '0')}.jpg`;
const player: Player = {
id,
name,
avatar: avatarStr,
isLeader: false
};
this.state.players.push(player);
this.log(`${name} se ha unido a la partida.`);
return player;
}
removePlayer(id: string) {
this.state.players = this.state.players.filter(p => p.id !== id);
}
startGame(): boolean {
const count = this.state.players.length;
if (count < 5 || count > 10) return false;
const config = GAME_CONFIG[count as keyof typeof GAME_CONFIG];
// 1. Asignar Roles
this.assignRoles(config.good, config.evil);
// 2. Asignar Líder inicial aleatorio
const leaderIndex = Math.floor(Math.random() * count);
this.state.players[leaderIndex].isLeader = true;
this.state.currentLeaderId = this.state.players[leaderIndex].id;
// 3. Iniciar juego - Fase Votación de Líder
this.state.phase = GamePhase.VOTE_LEADER;
this.state.leaderVotes = {}; // Reset votes
this.state.currentRound = 1;
this.log('¡La partida ha comenzado! Ahora deben votar para confirmar al Líder.');
return true;
}
// ... assignRoles se mantiene igual ...
private assignRoles(goodCount: number, evilCount: number) {
// Roles obligatorios
const roles: Role[] = [Role.MERLIN, Role.ASSASSIN];
// Rellenar resto de malos
for (let i = 0; i < evilCount - 1; i++) roles.push(Role.MINION);
// Rellenar resto de buenos
for (let i = 0; i < goodCount - 1; i++) roles.push(Role.LOYAL_SERVANT);
// Barajar roles
const shuffledRoles = roles.sort(() => Math.random() - 0.5);
// Asignar a jugadores
this.state.players.forEach((player, index) => {
player.role = shuffledRoles[index];
// Asignar facción basada en el rol
if ([Role.MERLIN, Role.PERCIVAL, Role.LOYAL_SERVANT].includes(player.role)) {
player.faction = Faction.RESISTANCE;
} else {
player.faction = Faction.SPIES;
}
});
}
// --- LOGICA DE VOTACIÓN DE LÍDER ---
voteLeader(playerId: string, approve: boolean | null) {
this.state.leaderVotes[playerId] = approve;
// Comprobar si todos han votado
if (Object.keys(this.state.leaderVotes).length === this.state.players.length) {
this.resolveLeaderVote();
}
}
private resolveLeaderVote() {
const votes = Object.values(this.state.leaderVotes);
const approves = votes.filter(v => v === true).length;
const rejects = votes.filter(v => v === false).length;
// Los nulos (timeout) no suman a ninguno, o cuentan como reject implícito?
// "Si llega a 0... su voto no cuenta". Simplemente no suma.
this.log(`Votación de Líder: ${approves} A favor - ${rejects} En contra.`);
if (approves > rejects) {
// Líder Aprobado -> Fase de Construcción de Equipo
this.state.phase = GamePhase.TEAM_BUILDING;
this.state.proposedTeam = []; // Reset team selection
this.log('Líder confirmado. Ahora debe proponer un equipo.');
} else {
// Líder Rechazado -> Siguiente líder
this.log('Líder rechazado. Pasando turno al siguiente jugador.');
this.nextLeader(); // Esto pondrá phase en VOTE_LEADER de nuevo
}
}
proposeTeam(playerIds: string[]): boolean {
// Validar tamaño del equipo según la ronda
const config = GAME_CONFIG[this.state.players.length as keyof typeof GAME_CONFIG];
const requiredSize = config.quests[this.state.currentRound - 1];
if (playerIds.length !== requiredSize) return false;
this.state.proposedTeam = playerIds;
this.state.phase = GamePhase.VOTING_TEAM;
this.state.teamVotes = {}; // Resetear votos
this.log(`El líder ha propuesto un equipo de ${playerIds.length} personas.`);
return true;
}
voteForTeam(playerId: string, approve: boolean) {
this.state.teamVotes[playerId] = approve;
// Comprobar si todos han votado
if (Object.keys(this.state.teamVotes).length === this.state.players.length) {
this.resolveTeamVote();
}
}
private resolveTeamVote() {
const votes = Object.values(this.state.teamVotes);
const approves = votes.filter(v => v).length;
const rejects = votes.length - approves;
this.log(`Votación completada: ${approves} A favor - ${rejects} En contra.`);
if (approves > rejects) {
// Equipo Aprobado
this.state.phase = GamePhase.MISSION;
this.state.missionVotes = [];
this.state.failedVotesCount = 0;
this.log('El equipo ha sido aprobado. ¡Comienza la misión!');
} else {
// Equipo Rechazado
this.state.failedVotesCount++;
this.state.proposedTeam = [];
if (this.state.failedVotesCount >= 5) {
this.endGame(Faction.SPIES, 'Se han rechazado 5 equipos consecutivos.');
} else {
this.nextLeader(); // Pasa a VOTE_LEADER
this.log('El equipo fue rechazado. El liderazgo pasa al siguiente jugador.');
}
}
}
voteMission(success: boolean) {
this.state.missionVotes.push(success);
// Comprobar si todos los miembros del equipo han votado
if (this.state.missionVotes.length === this.state.proposedTeam.length) {
this.resolveMission();
}
}
private resolveMission() {
const fails = this.state.missionVotes.filter(v => !v).length;
const successes = this.state.missionVotes.filter(v => v).length;
const playerCount = this.state.players.length;
const round = this.state.currentRound;
// Regla especial: 4ta misión con 7+ jugadores necesita 2 fallos
let failsRequired = 1;
if (playerCount >= 7 && round === 4) {
failsRequired = 2;
}
const isSuccess = fails < failsRequired;
// 1. BARAJAR los votos para que no se sepa quién votó qué
const shuffledVotes = [...this.state.missionVotes].sort(() => Math.random() - 0.5);
// 2. Guardar en el histórico
const missionRecord = {
round,
team: [...this.state.proposedTeam],
votes: shuffledVotes,
successes,
fails,
isSuccess,
leaderId: this.state.currentLeaderId
};
this.state.missionHistory.push(missionRecord);
// 3. Actualizar resultado de la quest
this.state.questResults[round - 1] = isSuccess;
// 4. Pasar a fase MISSION_REVEAL (mostrar cartas una a una)
this.state.phase = GamePhase.MISSION_REVEAL;
this.state.revealedVotes = shuffledVotes; // Las cartas a revelar
this.log(`Misión ${round} completada. Revelando votos...`);
}
// Método para avanzar desde MISSION_REVEAL a MISSION_RESULT
finishReveal() {
this.state.phase = GamePhase.MISSION_RESULT;
}
// Método para avanzar desde MISSION_RESULT y continuar el juego
finishMissionResult() {
const round = this.state.currentRound;
const isSuccess = this.state.questResults[round - 1];
this.log(`Misión ${round}: ${isSuccess ? 'ÉXITO' : 'FRACASO'}`);
// Comprobar condiciones de victoria
const successes = this.state.questResults.filter(r => r === true).length;
const failures = this.state.questResults.filter(r => r === false).length;
if (failures >= 3) {
this.endGame(Faction.SPIES, 'Tres misiones han fracasado.');
} else if (successes >= 3) {
this.state.phase = GamePhase.ASSASSIN_PHASE;
this.log('¡La Resistencia ha triunfado! Pero el Asesino tiene una última oportunidad...');
} else {
// Siguiente ronda
this.state.currentRound++;
this.nextLeader(); // Esto pone phase = VOTE_LEADER
this.state.proposedTeam = [];
this.state.missionVotes = [];
this.state.revealedVotes = [];
}
}
assassinKill(targetId: string) {
const target = this.state.players.find(p => p.id === targetId);
if (target && target.role === Role.MERLIN) {
this.endGame(Faction.SPIES, '¡El Asesino ha eliminado a Marlenne (Merlín)!');
} else {
this.endGame(Faction.RESISTANCE, 'El Asesino ha fallado. ¡La Resistencia gana!');
}
}
private nextLeader() {
const currentIdx = this.state.players.findIndex(p => p.id === this.state.currentLeaderId);
const nextIdx = (currentIdx + 1) % this.state.players.length;
this.state.players[currentIdx].isLeader = false;
this.state.players[nextIdx].isLeader = true;
this.state.currentLeaderId = this.state.players[nextIdx].id;
// Fase de confirmar al nuevo líder
this.state.phase = GamePhase.VOTE_LEADER;
this.state.leaderVotes = {};
}
private endGame(winner: Faction, reason: string) {
this.state.winner = winner;
this.state.phase = GamePhase.GAME_OVER;
this.log(`FIN DEL JUEGO. Victoria para ${winner}. Razón: ${reason}`);
}
private log(message: string) {
this.state.history.push(message);
// Mantener solo los últimos 50 mensajes
if (this.state.history.length > 50) this.state.history.shift();
}
}

15
server/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": false,
"skipLibCheck": true
},
"include": [
"src/**/*",
"../shared/**/*"
]
}