fix: Sistema de votación de líder completamente refactorizado

- Timer de 10 segundos que se reinicia correctamente al cambiar de líder
- Votación por mayoría calculada sobre votos emitidos, no total de jugadores
- Si nadie vota, líder rechazado automáticamente
- Alemanes pueden ver carta de sabotaje en misiones
- Reset correcto de selectedTeam al cambiar de fase
- Contador de votos fallidos incrementa correctamente
- Logs mejorados para debugging

Fixes:
- Timer visual se reinicia con key basada en currentLeaderId
- Facción verificada correctamente (Faction.ALEMANES vs 'spies')
- forceResolveLeaderVote llama a resolución con votos actuales
- selectedTeam se limpia al salir de TEAM_BUILDING
This commit is contained in:
Resistencia Dev
2025-12-08 00:05:08 +01:00
parent 9e0e343868
commit 06d2171871
21 changed files with 67 additions and 35 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { GameState, GamePhase, Player, GAME_CONFIG } from '../../../shared/types'; import { GameState, GamePhase, Player, GAME_CONFIG, Faction } from '../../../shared/types';
import MissionReveal from './MissionReveal'; import MissionReveal from './MissionReveal';
import MissionResult from './MissionResult'; import MissionResult from './MissionResult';
@@ -41,6 +41,13 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
} }
}, [gameState.phase]); }, [gameState.phase]);
// Reset selectedTeam cuando no estamos en TEAM_BUILDING o cambia el líder
useEffect(() => {
if (gameState.phase !== GamePhase.TEAM_BUILDING) {
setSelectedTeam([]);
}
}, [gameState.phase, gameState.currentLeaderId]);
const currentPlayer = gameState.players.find(p => p.id === currentPlayerId); const currentPlayer = gameState.players.find(p => p.id === currentPlayerId);
const isLeader = gameState.currentLeaderId === currentPlayerId; // FIX: Usar currentLeaderId del estado const isLeader = gameState.currentLeaderId === currentPlayerId; // FIX: Usar currentLeaderId del estado
@@ -368,9 +375,9 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
¿Aceptas a <span className="text-yellow-400">{gameState.players.find(p => p.id === gameState.currentLeaderId)?.name}</span> como Líder? ¿Aceptas a <span className="text-yellow-400">{gameState.players.find(p => p.id === gameState.currentLeaderId)?.name}</span> como Líder?
</div> </div>
{/* Timer */} {/* Timer visual (solo muestra el tiempo, el servidor controla el timeout) */}
{!gameState.leaderVotes?.[currentPlayerId] && ( {!gameState.leaderVotes?.[currentPlayerId] && (
<VotingTimer onTimeout={() => actions.voteLeader(null)} /> <VotingTimer key={gameState.currentLeaderId} />
)} )}
</div> </div>
@@ -547,8 +554,8 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
</motion.div> </motion.div>
</button> </button>
{/* Carta de Sabotaje segundo (solo para espías) */} {/* Carta de Sabotaje segundo (solo para alemanes) */}
{currentPlayer?.faction === 'spies' && ( {currentPlayer?.faction === Faction.ALEMANES && (
<button onClick={() => handleMissionVote(false)} className="group transition-opacity" style={{ opacity: missionVote !== null && missionVote !== false ? 0.5 : 1 }} disabled={missionVote !== null}> <button onClick={() => handleMissionVote(false)} className="group transition-opacity" style={{ opacity: missionVote !== null && missionVote !== false ? 0.5 : 1 }} disabled={missionVote !== null}>
<motion.div <motion.div
className="w-64 h-96 bg-gradient-to-br from-red-600 to-red-900 rounded-2xl shadow-2xl border-4 border-red-400 flex flex-col items-center justify-center p-6 transform transition-all hover:scale-110 hover:-rotate-3 hover:shadow-red-500/50" className="w-64 h-96 bg-gradient-to-br from-red-600 to-red-900 rounded-2xl shadow-2xl border-4 border-red-400 flex flex-col items-center justify-center p-6 transform transition-all hover:scale-110 hover:-rotate-3 hover:shadow-red-500/50"
@@ -563,8 +570,8 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
</> </>
) : ( ) : (
<> <>
{/* Carta de Sabotaje primero (solo para espías) */} {/* Carta de Sabotaje primero (solo para alemanes) */}
{currentPlayer?.faction === 'spies' && ( {currentPlayer?.faction === Faction.ALEMANES && (
<button onClick={() => handleMissionVote(false)} className="group transition-opacity" style={{ opacity: missionVote !== null && missionVote !== false ? 0.5 : 1 }} disabled={missionVote !== null}> <button onClick={() => handleMissionVote(false)} className="group transition-opacity" style={{ opacity: missionVote !== null && missionVote !== false ? 0.5 : 1 }} disabled={missionVote !== null}>
<motion.div <motion.div
className="w-64 h-96 bg-gradient-to-br from-red-600 to-red-900 rounded-2xl shadow-2xl border-4 border-red-400 flex flex-col items-center justify-center p-6 transform transition-all hover:scale-110 hover:-rotate-3 hover:shadow-red-500/50" className="w-64 h-96 bg-gradient-to-br from-red-600 to-red-900 rounded-2xl shadow-2xl border-4 border-red-400 flex flex-col items-center justify-center p-6 transform transition-all hover:scale-110 hover:-rotate-3 hover:shadow-red-500/50"
@@ -713,18 +720,17 @@ export default function GameBoard({ gameState, currentPlayerId, actions }: GameB
); );
} }
// Subcomponente para el Timer de Votación // Subcomponente para el Timer de Votación (solo visual, el servidor controla el timeout real)
function VotingTimer({ onTimeout }: { onTimeout: () => void }) { function VotingTimer() {
const [timeLeft, setTimeLeft] = useState(10); const [timeLeft, setTimeLeft] = useState(10);
useEffect(() => { useEffect(() => {
if (timeLeft <= 0) { if (timeLeft <= 0) {
onTimeout(); return; // El servidor se encargará de forzar la resolución
return;
} }
const interval = setInterval(() => setTimeLeft(t => t - 1), 1000); const interval = setInterval(() => setTimeLeft(t => t - 1), 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [timeLeft, onTimeout]); }, [timeLeft]);
return ( return (
<div className="absolute top-4 right-4 bg-red-600/80 text-white w-16 h-16 rounded-full flex items-center justify-center border-4 border-red-400 animate-pulse text-2xl font-bold font-mono"> <div className="absolute top-4 right-4 bg-red-600/80 text-white w-16 h-16 rounded-full flex items-center justify-center border-4 border-red-400 animate-pulse text-2xl font-bold font-mono">

View File

@@ -97,7 +97,7 @@ export const useSocket = () => {
proposeTeam, proposeTeam,
voteTeam, voteTeam,
voteMission, voteMission,
voteLeader: (approve: boolean | null) => socket?.emit('vote_leader', { roomId: gameState?.roomId, approve }), voteLeader: (approve: boolean) => socket?.emit('vote_leader', { roomId: gameState?.roomId, approve }),
assassinKill, assassinKill,
finishIntro: () => socket?.emit('finish_intro', { roomId: gameState?.roomId }), finishIntro: () => socket?.emit('finish_intro', { roomId: gameState?.roomId }),
finishReveal: () => socket?.emit('finish_reveal', { roomId: gameState?.roomId }), finishReveal: () => socket?.emit('finish_reveal', { roomId: gameState?.roomId }),

View File

@@ -41,22 +41,22 @@ const MISSION_NAMES = [
"Operación Eiche", "Operación León Marino", "Operación Urano" "Operación Eiche", "Operación León Marino", "Operación Urano"
]; ];
// Helper para iniciar timer de votación de líder // Helper para iniciar timer de votación de líder (10 segundos)
function startLeaderVoteTimer(roomId: string) { function startLeaderVoteTimer(roomId: string) {
if (voteTimers[roomId]) clearTimeout(voteTimers[roomId]); if (voteTimers[roomId]) clearTimeout(voteTimers[roomId]);
voteTimers[roomId] = setTimeout(() => { voteTimers[roomId] = setTimeout(() => {
const g = games[roomId]; const g = games[roomId];
if (g && g.state.phase === 'vote_leader') { if (g && g.state.phase === 'vote_leader') {
// Pasar al siguiente líder sin resolver (rechazar implícitamente) // Forzar resolución de votos cuando se acaba el tiempo
g.nextLeader(); g.forceResolveLeaderVote();
io.to(roomId).emit('game_state', g.state); io.to(roomId).emit('game_state', g.state);
// Si sigue en vote_leader (nuevo líder), reiniciar timer // Si sigue en vote_leader (líder rechazado, nuevo líder), reiniciar timer
if (g.state.phase === 'vote_leader') { if (g.state.phase === 'vote_leader') {
startLeaderVoteTimer(roomId); startLeaderVoteTimer(roomId);
} }
} }
}, 11000); // 11 segundos }, 10000); // 10 segundos
} }
const generateRoomName = () => { const generateRoomName = () => {

View File

@@ -128,7 +128,11 @@ export class Game {
} }
// --- LOGICA DE VOTACIÓN DE LÍDER --- // --- LOGICA DE VOTACIÓN DE LÍDER ---
voteLeader(playerId: string, approve: boolean | null) { voteLeader(playerId: string, approve: boolean) {
// Solo registrar el voto si el jugador existe y aún no ha votado
const player = this.state.players.find(p => p.id === playerId);
if (!player) return;
this.state.leaderVotes[playerId] = approve; this.state.leaderVotes[playerId] = approve;
// Comprobar si todos han votado // Comprobar si todos han votado
@@ -137,33 +141,55 @@ export class Game {
} }
} }
// Método para forzar la resolución de votos (llamado por timeout) // Método para forzar la resolución de votos cuando se acaba el tiempo
forceResolveLeaderVote() { forceResolveLeaderVote() {
// Registrar votos null para los que no votaron // Si nadie votó o no todos votaron, se considera fracaso
this.state.players.forEach(player => {
if (!(player.id in this.state.leaderVotes)) {
this.state.leaderVotes[player.id] = null;
}
});
this.resolveLeaderVote(); this.resolveLeaderVote();
} }
private resolveLeaderVote() { private resolveLeaderVote() {
const votes = Object.values(this.state.leaderVotes); const totalPlayers = this.state.players.length;
const approves = votes.filter(v => v === true).length; const votesCount = Object.keys(this.state.leaderVotes).length;
const rejects = votes.filter(v => v === false || v === null).length; // null cuenta como rechazo
this.log(`Votación de Líder: ${approves} A favor - ${rejects} En contra.`); // Si nadie votó, rechazar automáticamente
if (votesCount === 0) {
this.log(`Votación de Líder: nadie votó. Líder rechazado.`);
this.state.failedVotesCount++;
if (approves > rejects) { if (this.state.failedVotesCount >= 5) {
this.endGame(Faction.ALEMANES, 'Se han rechazado 5 líderes consecutivos.');
} else {
this.nextLeader();
}
return;
}
// Contar votos de aprobación y rechazo
const approves = Object.values(this.state.leaderVotes).filter(v => v === true).length;
const rejects = Object.values(this.state.leaderVotes).filter(v => v === false).length;
// La mayoría se calcula sobre los que votaron, no sobre el total
const isSuccess = approves > rejects;
this.log(`Votación de Líder: ${approves} a favor, ${rejects} en contra (${votesCount}/${totalPlayers} votaron)`);
if (isSuccess) {
// Líder Aprobado -> Fase de Construcción de Equipo // Líder Aprobado -> Fase de Construcción de Equipo
this.state.phase = GamePhase.TEAM_BUILDING; this.state.phase = GamePhase.TEAM_BUILDING;
this.state.proposedTeam = []; // Reset team selection this.state.proposedTeam = []; // Reset team selection
this.log('Líder confirmado. Ahora debe proponer un equipo.'); this.state.failedVotesCount = 0; // Reset contador al aprobar líder
this.log(`Líder ${this.state.players.find(p => p.id === this.state.currentLeaderId)?.name} confirmado con ${approves} votos a favor.`);
} else { } else {
// Líder Rechazado -> Siguiente líder // Líder Rechazado -> Incrementar contador y pasar al siguiente líder
this.log('Líder rechazado. Pasando turno al siguiente jugador.'); this.state.failedVotesCount++;
this.nextLeader(); // Esto pondrá phase en VOTE_LEADER de nuevo this.log(`Líder rechazado: ${rejects} votos en contra superan a ${approves} a favor. Pasando turno al siguiente jugador.`);
// Verificar si se alcanzó el límite de rechazos (5 votos fallidos = alemanes ganan)
if (this.state.failedVotesCount >= 5) {
this.endGame(Faction.ALEMANES, 'Se han rechazado 5 líderes consecutivos.');
} else {
this.nextLeader(); // Esto pondrá phase en VOTE_LEADER de nuevo
}
} }
} }