const { v4: uuidv4 } = require('uuid'); const youtubeAPI = require('./youtube-api'); /** * Game state and logic manager for Song Battle application */ class GameManager { constructor() { this.lobbies = new Map(); // Map of lobbyId -> lobby object this.playerToLobby = new Map(); // Map of playerId -> lobbyId for quick lookup } /** * Create a new game lobby * @param {string} hostId - ID of the host player * @param {string} hostName - Name of the host player * @returns {Object} New lobby data and ID */ createLobby(hostId, hostName) { // Generate a simple 6-character lobby code const lobbyId = this._generateLobbyCode(); // Create lobby object const lobby = { id: lobbyId, hostId: hostId, state: 'LOBBY', // LOBBY -> SONG_SUBMISSION -> VOTING -> FINISHED settings: { songsPerPlayer: 3, maxPlayers: 10, minPlayers: 3, hidePlayerNames: false }, players: [{ id: hostId, name: hostName, isConnected: true, isReady: false, songs: [], songCount: 0 }], songs: [], // All submitted songs currentBattle: null, battles: [], // History of all battles finalWinner: null }; // Store lobby and player-to-lobby mapping this.lobbies.set(lobbyId, lobby); this.playerToLobby.set(hostId, lobbyId); return { lobby, lobbyId }; } /** * Join an existing lobby * @param {string} playerId - ID of joining player * @param {string} playerName - Name of joining player * @param {string} lobbyId - ID of lobby to join * @returns {Object} Lobby data or error */ joinLobby(playerId, playerName, lobbyId) { // Check if lobby exists if (!this.lobbies.has(lobbyId)) { return { error: 'Lobby not found' }; } const lobby = this.lobbies.get(lobbyId); // Check if lobby is in correct state if (lobby.state !== 'LOBBY') { return { error: 'Cannot join: game already in progress' }; } // Check if player limit is reached if (lobby.players.length >= lobby.settings.maxPlayers) { return { error: 'Lobby is full' }; } // Check if player name is already taken if (lobby.players.some(p => p.name === playerName && p.isConnected)) { return { error: 'Name already taken' }; } // Check if player is rejoining const existingPlayerIndex = lobby.players.findIndex(p => p.name === playerName && !p.isConnected); if (existingPlayerIndex >= 0) { // Update player ID and connection status lobby.players[existingPlayerIndex].id = playerId; lobby.players[existingPlayerIndex].isConnected = true; this.playerToLobby.set(playerId, lobbyId); return { lobby, lobbyId }; } // Add new player lobby.players.push({ id: playerId, name: playerName, isConnected: true, isReady: false, songs: [], songCount: 0 }); // Update map this.playerToLobby.set(playerId, lobbyId); return { lobby, lobbyId }; } /** * Handle player reconnection to a lobby * @param {string} playerId - New ID of the reconnecting player * @param {string} lobbyId - ID of the lobby * @param {string} playerName - Name of the player * @param {Object} lastKnownState - Last known game state from client * @returns {Object} Lobby data or error */ handleReconnect(playerId, lobbyId, playerName, lastKnownState = null) { // Check if lobby exists if (!this.lobbies.has(lobbyId)) { return { error: 'Lobby not found' }; } const lobby = this.lobbies.get(lobbyId); // Find player by name const playerIndex = lobby.players.findIndex(p => p.name === playerName); if (playerIndex === -1) { return { error: 'Player not found in lobby' }; } // Update player ID and connection status lobby.players[playerIndex].id = playerId; lobby.players[playerIndex].isConnected = true; // Update map this.playerToLobby.set(playerId, lobbyId); // Handle state reconciliation if lastKnownState is provided if (lastKnownState && lastKnownState.gameState && lastKnownState.timestamp) { console.log(`Reconciling player state based on last known state: ${lastKnownState.gameState} from ${new Date(lastKnownState.timestamp).toISOString()}`); // If the client's last known state was VOTING and the current battle has votes if (lastKnownState.gameState === 'VOTING' && lastKnownState.currentBattle && lastKnownState.playerState && lastKnownState.playerState.hasVoted) { // The player already voted in this battle according to their local state // If we still have the same battle, ensure their vote is counted if (lobby.currentBattle && lastKnownState.currentBattle.round === lobby.currentBattle.round) { console.log(`Player ${playerName} had voted in battle round ${lastKnownState.currentBattle.round}, checking if vote needs restoration`); // Check if their vote is missing const hasVote = lobby.currentBattle.votes && (lobby.currentBattle.votes.has(playerId) || Object.keys(lobby.currentBattle.votes).includes(playerId)); // If their vote is missing, we need to determine which song they voted for if (!hasVote && lastKnownState.currentBattle.votes) { // Find which song they voted for const votedSongId = lastKnownState.currentBattle.votes[lobby.players[playerIndex].id]; if (votedSongId) { console.log(`Restoring player ${playerName}'s vote for song ${votedSongId}`); // Recreate their vote this.submitVote(playerId, votedSongId, true); } } } } // If the client's last known state was SONG_SUBMISSION and they were ready if (lastKnownState.gameState === 'SONG_SUBMISSION' && lastKnownState.playerState && lastKnownState.playerState.isReady && lobby.state === 'SONG_SUBMISSION' && !lobby.players[playerIndex].isReady) { console.log(`Restoring ready status for player ${playerName}`); lobby.players[playerIndex].isReady = true; // Check if all players are now ready const allReady = lobby.players.every(p => p.isReady || !p.isConnected); if (allReady) { console.log('All players ready after reconnection, starting tournament...'); this._startTournament(lobby); return { lobby, lobbyId, tournamentStarted: true }; } } } return { lobby, lobbyId }; } /** * Update lobby settings * @param {string} playerId - ID of the host player * @param {Object} settings - New settings object * @returns {Object} Updated lobby data or error */ updateSettings(playerId, settings) { const lobbyId = this.playerToLobby.get(playerId); if (!lobbyId) { return { error: 'Player not in a lobby' }; } const lobby = this.lobbies.get(lobbyId); // Check if player is the host if (lobby.hostId !== playerId) { return { error: 'Only the host can update settings' }; } // Validate settings if (settings.songsPerPlayer < 1 || settings.songsPerPlayer > 10) { return { error: 'Songs per player must be between 1 and 10' }; } if (settings.maxPlayers < 3 || settings.maxPlayers > 20) { return { error: 'Max players must be between 3 and 20' }; } // Validate boolean settings if (settings.hidePlayerNames !== undefined && typeof settings.hidePlayerNames !== 'boolean') { settings.hidePlayerNames = Boolean(settings.hidePlayerNames); } // Update settings lobby.settings = { ...lobby.settings, ...settings }; return { lobby, lobbyId }; } /** * Start the game from lobby state * @param {string} playerId - ID of the host player * @returns {Object} Updated lobby data or error */ startGame(playerId) { const lobbyId = this.playerToLobby.get(playerId); if (!lobbyId) { return { error: 'Player not in a lobby' }; } const lobby = this.lobbies.get(lobbyId); // Check if player is the host if (lobby.hostId !== playerId) { return { error: 'Only the host can start the game' }; } // Check if in correct state if (lobby.state !== 'LOBBY') { return { error: 'Game already started' }; } // Check if enough players if (lobby.players.length < lobby.settings.minPlayers) { return { error: `Need at least ${lobby.settings.minPlayers} players to start` }; } // Move to song submission state lobby.state = 'SONG_SUBMISSION'; return { lobby, lobbyId }; } /** * Add a song for a player * @param {string} playerId - ID of the player * @param {Object} song - Song data (title, artist, youtubeLink) * @returns {Object} Updated lobby data or error */ async addSong(playerId, song) { console.log(`[DEBUG] addSong called for player ID: ${playerId}`); // Check if player is in a lobby let lobbyId = this.playerToLobby.get(playerId); if (!lobbyId) { console.log(`[DEBUG] No lobby mapping found for player ID: ${playerId}, trying to locate player...`); // Search all lobbies for this player let foundLobby = null; let foundPlayerIndex = -1; for (const [id, lobby] of this.lobbies.entries()) { const playerIndex = lobby.players.findIndex(p => p.id === playerId); if (playerIndex !== -1) { foundLobby = lobby; lobbyId = id; foundPlayerIndex = playerIndex; console.log(`[DEBUG] Found player in lobby ${id} at index ${playerIndex}`); // Fix the mapping for future requests this.playerToLobby.set(playerId, id); break; } } if (!foundLobby) { console.log(`[DEBUG] Player not found in any lobby. Available lobbies: ${Array.from(this.lobbies.keys())}`); return { error: 'Player not in a lobby' }; } } const lobby = this.lobbies.get(lobbyId); if (!lobby) { console.log(`[DEBUG] Lobby ID ${lobbyId} not found`); return { error: 'Lobby not found' }; } // Log lobby state for debugging console.log(`[DEBUG] Lobby ${lobbyId} state: ${lobby.state}`); console.log(`[DEBUG] Lobby players: ${JSON.stringify(lobby.players.map(p => ({id: p.id, name: p.name})))}`); // Check if in correct state if (lobby.state !== 'SONG_SUBMISSION') { return { error: 'Cannot add songs at this time' }; } // Get player const playerIndex = lobby.players.findIndex(p => p.id === playerId); if (playerIndex === -1) { console.log(`[DEBUG] Player ID ${playerId} not found in lobby ${lobbyId}.`); return { error: 'Player not found in lobby' }; } // Check if player can add more songs if (lobby.players[playerIndex].songs.length >= lobby.settings.songsPerPlayer) { return { error: 'Maximum number of songs reached' }; } // If we have a YouTube link, validate it if (song.youtubeLink) { const videoId = await youtubeAPI.extractVideoId(song.youtubeLink); if (!videoId) { return { error: 'Invalid YouTube link' }; } } // Handle async metadata fetching return this._addSongToPlayer(playerIndex, lobby, lobbyId, song, playerId); } /** * Search for songs on YouTube * @param {string} query - Search query * @returns {Promise} Search results */ async searchYouTube(query) { try { return await youtubeAPI.searchYouTube(query); } catch (error) { console.error('Error searching YouTube:', error); return { error: 'Failed to search YouTube' }; } } /** * Get metadata for a single YouTube video * @param {string} videoId - YouTube video ID * @returns {Promise} Video metadata */ async getYouTubeVideoMetadata(videoId) { try { return await youtubeAPI.getVideoMetadata(videoId); } catch (error) { console.error('Error getting YouTube video metadata:', error); return { error: 'Failed to get video metadata' }; } } /** * Helper method to add a song to a player * @param {number} playerIndex - Index of the player in the lobby * @param {Object} lobby - The lobby object * @param {string} lobbyId - ID of the lobby * @param {Object} song - Song data to add * @param {string} playerId - ID of the player adding the song * @returns {Object|Promise} Updated lobby data * @private */ async _addSongToPlayer(playerIndex, lobby, lobbyId, song, playerId) { // Generate song ID const songId = uuidv4(); // Prepare song data let title = song.title; let artist = song.artist; let thumbnail = song.thumbnail || null; // Use client-provided thumbnail if available // If YouTube link is provided but title/artist are empty, try to fetch metadata if (song.youtubeLink && (!title || !artist || title === 'Unknown' || artist === 'Unknown' || !thumbnail)) { try { // Extract video ID from YouTube link const videoId = youtubeAPI.extractVideoId(song.youtubeLink); if (videoId) { console.log(`Getting metadata for YouTube video: ${videoId}`); // Fetch metadata from YouTube API const metadata = await youtubeAPI.getVideoMetadata(videoId); // Only update if we don't have values or they're generic if (!title || title === 'Unknown') title = metadata.title || 'Unknown'; if (!artist || artist === 'Unknown') artist = metadata.artist || 'Unknown'; if (!thumbnail) thumbnail = metadata.thumbnail; console.log(`Fetched metadata: "${title}" by ${artist}`); } } catch (error) { console.error('Error fetching YouTube metadata:', error.message); // Continue with user-provided data if API fails } } // Add song to player and global song list const newSong = { id: songId, title: title, artist: artist, youtubeLink: song.youtubeLink || '', thumbnail: thumbnail, submittedById: playerId, submittedByName: lobby.players[playerIndex].name }; // Add song to both player's list and global list console.log(`[DEBUG] Adding song "${title}" to player at index ${playerIndex} in lobby ${lobbyId}`); lobby.players[playerIndex].songs.push(newSong); lobby.players[playerIndex].songCount = lobby.players[playerIndex].songs.length; lobby.songs.push(newSong); console.log(`[DEBUG] Updated player song count: ${lobby.players[playerIndex].songCount}`); console.log(`[DEBUG] Total songs in lobby: ${lobby.songs.length}`); // Make sure we're returning a properly formed result with both lobby and lobbyId const result = { lobby, lobbyId }; // Validate that the result contains what we expect before returning if (!result.lobby || !result.lobbyId) { console.log(`[DEBUG] CRITICAL ERROR: Result is missing lobby or lobbyId after adding song`); console.log(`[DEBUG] Result keys: ${Object.keys(result)}`); } else { console.log(`[DEBUG] Song successfully added, returning result with valid lobby and lobbyId`); } return result; } /** * Remove a song for a player * @param {string} playerId - ID of the player * @param {string} songId - ID of the song to remove * @returns {Object} Updated lobby data or error */ removeSong(playerId, songId) { const lobbyId = this.playerToLobby.get(playerId); if (!lobbyId) { return { error: 'Player not in a lobby' }; } const lobby = this.lobbies.get(lobbyId); // Check if in correct state if (lobby.state !== 'SONG_SUBMISSION') { return { error: 'Cannot remove songs at this time' }; } // Get player const playerIndex = lobby.players.findIndex(p => p.id === playerId); if (playerIndex === -1) { return { error: 'Player not found in lobby' }; } // Find song in player's songs const songIndex = lobby.players[playerIndex].songs.findIndex(s => s.id === songId); if (songIndex === -1) { return { error: 'Song not found' }; } // Remove song from player's list lobby.players[playerIndex].songs.splice(songIndex, 1); lobby.players[playerIndex].songCount = lobby.players[playerIndex].songs.length; // Remove from global song list const globalSongIndex = lobby.songs.findIndex(s => s.id === songId); if (globalSongIndex !== -1) { lobby.songs.splice(globalSongIndex, 1); } // If player was ready, set to not ready if (lobby.players[playerIndex].isReady) { lobby.players[playerIndex].isReady = false; } return { lobby, lobbyId }; } /** * Set player ready status in song submission phase * @param {string} playerId - ID of the player * @returns {Object} Updated lobby data or error */ setPlayerReady(playerId) { const lobbyId = this.playerToLobby.get(playerId); if (!lobbyId) { return { error: 'Player not in a lobby' }; } const lobby = this.lobbies.get(lobbyId); // Check if in correct state if (lobby.state !== 'SONG_SUBMISSION') { return { error: 'Cannot set ready status at this time' }; } // Get player const playerIndex = lobby.players.findIndex(p => p.id === playerId); if (playerIndex === -1) { return { error: 'Player not found in lobby' }; } // Check if player has submitted enough songs if (lobby.players[playerIndex].songs.length < lobby.settings.songsPerPlayer) { return { error: 'Must submit all songs before ready' }; } // Set player as ready lobby.players[playerIndex].isReady = true; // Check if all players are ready const allReady = lobby.players.every(p => p.isReady || !p.isConnected); if (allReady) { console.log('All players ready, starting tournament...'); // Start the tournament by creating brackets this._startTournament(lobby); // Return an indicator that the tournament has started return { lobby, lobbyId, tournamentStarted: true }; } return { lobby, lobbyId }; } /** * Submit a vote for a song in a battle * @param {string} playerId - ID of the voting player * @param {string} songId - ID of the voted song * @param {boolean} isRestoredVote - Whether this is a restored vote during reconnection * @returns {Object} Updated lobby data or error */ submitVote(playerId, songId, isRestoredVote = false) { const lobbyId = this.playerToLobby.get(playerId); if (!lobbyId) { return { error: 'Player not in a lobby' }; } const lobby = this.lobbies.get(lobbyId); // Check if in correct state if (lobby.state !== 'VOTING') { return { error: 'Cannot vote at this time' }; } // Check if there's an active battle if (!lobby.currentBattle) { return { error: 'No active battle' }; } // For bye battles (automatic advancement), only allow the host to vote/advance if (lobby.currentBattle.bye === true && playerId !== lobby.hostId) { return { error: 'Only the host can advance bye rounds' }; } // Check if player has already voted in this battle if (lobby.currentBattle.votes && Object.prototype.hasOwnProperty.call(lobby.currentBattle.votes, playerId) && !isRestoredVote) { return { error: 'Already voted in this battle' }; } // Check if the voted song is part of the current battle if (songId !== lobby.currentBattle.song1.id && (lobby.currentBattle.song2 && songId !== lobby.currentBattle.song2.id)) { return { error: 'Invalid song ID' }; } // Find player name for display purposes const player = lobby.players.find(p => p.id === playerId); const playerName = player ? player.name : 'Unknown Player'; // Initialize votes Object if it doesn't exist if (!lobby.currentBattle.votes) { lobby.currentBattle.votes = {}; } // Record the vote with player name for UI display lobby.currentBattle.votes[playerId] = { songId, playerName }; // Update vote counts if (songId === lobby.currentBattle.song1.id) { lobby.currentBattle.song1Votes = (lobby.currentBattle.song1Votes || 0) + 1; } else if (lobby.currentBattle.song2) { lobby.currentBattle.song2Votes = (lobby.currentBattle.song2Votes || 0) + 1; } // Add a voteCount attribute for easier UI rendering lobby.currentBattle.voteCount = Object.keys(lobby.currentBattle.votes).length; // For bye battles, the host's vote is all that's needed if (lobby.currentBattle.bye === true && playerId === lobby.hostId) { // Determine winner (in bye battles, it's always song1) const winnerSongId = lobby.currentBattle.song1.id; lobby.currentBattle.winner = winnerSongId; // Save battle to history lobby.battles.push({ round: lobby.currentBattle.round, song1: lobby.currentBattle.song1, song2: lobby.currentBattle.song2, song1Votes: lobby.currentBattle.song1Votes, song2Votes: 0, winner: winnerSongId }); // Move to next battle or finish tournament this._moveToNextBattle(lobby); return { lobby, lobbyId }; } // For regular battles, check if all connected players have voted const connectedPlayers = lobby.players.filter(p => p.isConnected).length; const voteCount = Object.keys(lobby.currentBattle.votes).length; if (voteCount >= connectedPlayers) { // Determine winner const winnerSongId = lobby.currentBattle.song1Votes > lobby.currentBattle.song2Votes ? lobby.currentBattle.song1.id : lobby.currentBattle.song2.id; lobby.currentBattle.winner = winnerSongId; // Save battle to history lobby.battles.push({ round: lobby.currentBattle.round, song1: lobby.currentBattle.song1, song2: lobby.currentBattle.song2, song1Votes: lobby.currentBattle.song1Votes, song2Votes: lobby.currentBattle.song2Votes, winner: winnerSongId }); // Move to next battle or finish tournament this._moveToNextBattle(lobby); } return { lobby, lobbyId }; } /** * Handle player leaving a lobby * @param {string} playerId - ID of the player * @returns {Object} Updated lobby data or null if lobby is removed */ leaveLobby(playerId) { const lobbyId = this.playerToLobby.get(playerId); if (!lobbyId) { return null; // Player not in a lobby } const lobby = this.lobbies.get(lobbyId); // Remove player from lobby const playerIndex = lobby.players.findIndex(p => p.id === playerId); if (playerIndex !== -1) { // If game hasn't started, remove player completely if (lobby.state === 'LOBBY') { lobby.players.splice(playerIndex, 1); } else { // Otherwise just mark as disconnected lobby.players[playerIndex].isConnected = false; } } // Remove from map this.playerToLobby.delete(playerId); // If it's the host leaving and game hasn't started, assign new host or delete lobby if (lobby.hostId === playerId && lobby.state === 'LOBBY') { if (lobby.players.length > 0) { lobby.hostId = lobby.players[0].id; } else { this.lobbies.delete(lobbyId); return null; } } // If all players have left, remove the lobby const connectedPlayers = lobby.players.filter(p => p.isConnected).length; if (connectedPlayers === 0) { this.lobbies.delete(lobbyId); return null; } // For voting state, check if we need to progress if (lobby.state === 'VOTING' && lobby.currentBattle) { // Add null check for votes property const totalVotes = lobby.currentBattle.votes ? Object.keys(lobby.currentBattle.votes).length : 0; const remainingPlayers = lobby.players.filter(p => p.isConnected).length; if (totalVotes >= remainingPlayers && totalVotes > 0) { // Determine winner const winnerSongId = lobby.currentBattle.song1Votes > lobby.currentBattle.song2Votes ? lobby.currentBattle.song1.id : lobby.currentBattle.song2.id; lobby.currentBattle.winner = winnerSongId; // Save battle to history lobby.battles.push({ round: lobby.currentBattle.round, song1: lobby.currentBattle.song1, song2: lobby.currentBattle.song2, song1Votes: lobby.currentBattle.song1Votes, song2Votes: lobby.currentBattle.song2Votes, winner: winnerSongId }); // Move to next battle or finish tournament this._moveToNextBattle(lobby); } } return { lobby, lobbyId }; } /** * Handle player disconnection * @param {string} playerId - ID of the disconnected player * @returns {Object|null} Updated lobby data or null if no lobby found */ handleDisconnect(playerId) { return this.leaveLobby(playerId); } /** * Generate a unique lobby code * @returns {string} 6-character lobby code * @private */ _generateLobbyCode() { const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed ambiguous characters let result = ''; // Generate code until unique do { result = ''; for (let i = 0; i < 6; i++) { result += characters.charAt(Math.floor(Math.random() * characters.length)); } } while (this.lobbies.has(result)); return result; } /** * Start the tournament by creating brackets and first battle * @param {Object} lobby - Lobby object * @private */ _startTournament(lobby) { // Collect all submitted songs const songs = [...lobby.songs]; // Shuffle songs this._shuffleArray(songs); // Create tournament brackets const brackets = []; let round = 0; // Handle odd number of songs by giving a bye to one song if (songs.length % 2 !== 0 && songs.length > 1) { const byeSong = songs.pop(); brackets.push({ round, song1: byeSong, song2: null, // Bye bye: true, winner: null, // Set to null until host advances it song1Votes: 0, song2Votes: 0, votes: {} // Initialize votes as an empty object }); } // Create initial bracket pairs for (let i = 0; i < songs.length; i += 2) { if (i + 1 < songs.length) { brackets.push({ round, song1: songs[i], song2: songs[i + 1], song1Votes: 0, song2Votes: 0, winner: null, votes: {} }); } } // Store brackets and transition to voting state lobby.brackets = brackets; lobby.currentBracketIndex = 0; if (brackets.length > 0) { // Set the first battle lobby.state = 'VOTING'; // Ensure we create a proper battle object with all required fields lobby.currentBattle = { ...brackets[0], // Make sure votes is an object (not a Map) votes: brackets[0].votes || {}, voteCount: 0 }; console.log('Starting first battle:', lobby.currentBattle); } else { // Edge case: only one song submitted if (songs.length === 1) { lobby.state = 'FINISHED'; lobby.finalWinner = songs[0]; } } } /** * Move to next battle or finish tournament * @param {Object} lobby - Lobby object * @private */ _moveToNextBattle(lobby) { // Check if there are more battles in current round lobby.currentBracketIndex++; if (lobby.currentBracketIndex < lobby.brackets.length) { // Move to next battle lobby.currentBattle = lobby.brackets[lobby.currentBracketIndex]; return; } // Current round complete, check if tournament is finished const winners = lobby.brackets .filter(b => !b.bye) // Skip byes .map(b => { const winningSong = b.song1.id === b.winner ? b.song1 : b.song2; return winningSong; }); // Add byes to winners const byes = lobby.brackets .filter(b => b.bye) .map(b => b.song1); const nextRoundSongs = [...winners, ...byes]; // If only one song remains, we have a winner if (nextRoundSongs.length <= 1) { lobby.state = 'FINISHED'; lobby.finalWinner = nextRoundSongs[0]; lobby.currentBattle = null; return; } // Create brackets for next round const nextRound = []; const round = lobby.brackets[0].round + 1; // Handle odd number of songs by giving a bye to one song if (nextRoundSongs.length % 2 !== 0) { const byeSong = nextRoundSongs.pop(); nextRound.push({ round, song1: byeSong, song2: null, bye: true, winner: null, // Set to null until host advances it song1Votes: 0, song2Votes: 0, votes: {} // Initialize votes as an object }); } // Create pairs for next round for (let i = 0; i < nextRoundSongs.length; i += 2) { if (i + 1 < nextRoundSongs.length) { nextRound.push({ round, song1: nextRoundSongs[i], song2: nextRoundSongs[i + 1], song1Votes: 0, song2Votes: 0, winner: null, votes: {} }); } } // Update brackets and reset index lobby.brackets = nextRound; lobby.currentBracketIndex = 0; // Set first battle of new round if (nextRound.length > 0) { lobby.currentBattle = nextRound[0]; // Ensure votes is initialized as an object if (!lobby.currentBattle.votes) { lobby.currentBattle.votes = {}; } // Initialize vote count for UI lobby.currentBattle.voteCount = 0; } } /** * Shuffle array in-place using Fisher-Yates algorithm * @param {Array} array - Array to shuffle * @private */ _shuffleArray(array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } } } // Export GameManager class module.exports = GameManager;