864 lines
26 KiB
JavaScript
864 lines
26 KiB
JavaScript
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,
|
|
requireYoutubeLinks: 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
|
|
* @returns {Object} Lobby data or error
|
|
*/
|
|
handleReconnect(playerId, lobbyId, playerName) {
|
|
// 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);
|
|
|
|
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' };
|
|
}
|
|
|
|
// 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) {
|
|
// If no mapping exists, try to find the player in any lobby
|
|
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}.`);
|
|
|
|
// First try: Find the host if this is a host request
|
|
if (playerId === lobby.hostId || lobby.players.some(p => p.id === lobby.hostId)) {
|
|
console.log('[DEBUG] This appears to be the host. Looking for host player...');
|
|
const hostIndex = lobby.players.findIndex(p => p.id === lobby.hostId);
|
|
if (hostIndex !== -1) {
|
|
console.log(`[DEBUG] Found host at index ${hostIndex}`);
|
|
return this._addSongToPlayer(hostIndex, lobby, lobbyId, song, playerId);
|
|
}
|
|
}
|
|
|
|
// Second try: Match any connected player as fallback
|
|
console.log('[DEBUG] Trying to find any connected player...');
|
|
const connectedIndex = lobby.players.findIndex(p => p.isConnected);
|
|
if (connectedIndex !== -1) {
|
|
console.log(`[DEBUG] Found connected player at index ${connectedIndex}`);
|
|
return this._addSongToPlayer(connectedIndex, lobby, lobbyId, song, playerId);
|
|
}
|
|
|
|
// If we still can't find a match, use the first player as last resort
|
|
if (lobby.players.length > 0) {
|
|
console.log('[DEBUG] Using first player as last resort');
|
|
return this._addSongToPlayer(0, lobby, lobbyId, song, playerId);
|
|
}
|
|
|
|
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' };
|
|
}
|
|
|
|
// We only require the YouTube link now
|
|
if (!song.youtubeLink) {
|
|
return { error: 'YouTube link is required' };
|
|
}
|
|
|
|
// If the YouTube link isn't valid, return an error
|
|
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<Array>} 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' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<Object>} 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
|
|
* @returns {Object} Updated lobby data or error
|
|
*/
|
|
submitVote(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 !== 'VOTING') {
|
|
return { error: 'Cannot vote at this time' };
|
|
}
|
|
|
|
// Check if there's an active battle
|
|
if (!lobby.currentBattle) {
|
|
return { error: 'No active battle' };
|
|
}
|
|
|
|
// Check if player has already voted in this battle
|
|
if (lobby.currentBattle.votes.has(playerId)) {
|
|
return { error: 'Already voted in this battle' };
|
|
}
|
|
|
|
// Check if the voted song is part of the current battle
|
|
if (songId !== lobby.currentBattle.song1.id && 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';
|
|
|
|
// Record the vote with player name for UI display
|
|
lobby.currentBattle.votes.set(playerId, {
|
|
songId,
|
|
playerName
|
|
});
|
|
|
|
// Update vote counts
|
|
if (songId === lobby.currentBattle.song1.id) {
|
|
lobby.currentBattle.song1Votes++;
|
|
} else {
|
|
lobby.currentBattle.song2Votes++;
|
|
}
|
|
|
|
// Add a voteCount attribute for easier UI rendering
|
|
lobby.currentBattle.voteCount = lobby.currentBattle.votes.size;
|
|
|
|
// Check if all connected players have voted
|
|
const connectedPlayers = lobby.players.filter(p => p.isConnected).length;
|
|
const voteCount = lobby.currentBattle.votes.size;
|
|
|
|
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?.size || 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: byeSong.id,
|
|
song1Votes: 0,
|
|
song2Votes: 0
|
|
});
|
|
}
|
|
|
|
// 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: new Map()
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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 a Map (it might be serialized incorrectly)
|
|
votes: brackets[0].votes || new Map(),
|
|
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: byeSong.id,
|
|
song1Votes: 0,
|
|
song2Votes: 0
|
|
});
|
|
}
|
|
|
|
// 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: new Map()
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|