// /server/game/instance/PlayerConnectionHandler.js const GAME_CONFIG = require('../../core/config'); const dataUtils = require('../../data/dataUtils'); // Потребуется для получения данных персонажа при реконнекте class PlayerConnectionHandler { constructor(gameInstance) { this.gameInstance = gameInstance; // Ссылка на основной GameInstance this.io = gameInstance.io; this.gameId = gameInstance.id; this.mode = gameInstance.mode; this.players = {}; // { socket.id: { id, socket, chosenCharacterKey, identifier, isTemporarilyDisconnected } } this.playerSockets = {}; // { playerIdRole: socket } this.playerCount = 0; this.reconnectTimers = {}; // { playerIdRole: { timerId, updateIntervalId, startTimeMs, durationMs } } this.pausedTurnState = null; // { remainingTime, forPlayerRoleIsPlayer, isAiCurrentlyMoving } console.log(`[PCH for Game ${this.gameId}] Initialized.`); } addPlayer(socket, chosenCharacterKey = 'elena', identifier) { console.log(`[PCH ${this.gameId}] addPlayer attempt. Socket: ${socket.id}, CharKey: ${chosenCharacterKey}, Identifier: ${identifier}`); const existingPlayerByIdentifier = Object.values(this.players).find(p => p.identifier === identifier); if (existingPlayerByIdentifier) { console.warn(`[PCH ${this.gameId}] Identifier ${identifier} already associated with player role ${existingPlayerByIdentifier.id} (socket ${existingPlayerByIdentifier.socket?.id}). Handling as potential reconnect.`); if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) { console.warn(`[PCH ${this.gameId}] Player ${identifier} trying to (re)join an already finished game. Emitting gameError.`); socket.emit('gameError', { message: 'Эта игра уже завершена.' }); this.gameInstance.gameManager._cleanupGame(this.gameId, `rejoin_attempt_to_finished_game_pch_${identifier}`); return false; } if (existingPlayerByIdentifier.isTemporarilyDisconnected) { return this.handlePlayerReconnected(existingPlayerByIdentifier.id, socket); } socket.emit('gameError', { message: 'Вы уже находитесь в этой игре. Попробуйте обновить страницу.' }); return false; } if (Object.keys(this.players).length >= 2 && this.playerCount >=2) { socket.emit('gameError', { message: 'Эта игра уже заполнена.' }); return false; } let assignedPlayerId; let actualCharacterKey = chosenCharacterKey || 'elena'; if (this.mode === 'ai') { if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) { socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' }); return false; } assignedPlayerId = GAME_CONFIG.PLAYER_ID; } else { // pvp if (!this.playerSockets[GAME_CONFIG.PLAYER_ID]) { assignedPlayerId = GAME_CONFIG.PLAYER_ID; } else if (!this.playerSockets[GAME_CONFIG.OPPONENT_ID]) { assignedPlayerId = GAME_CONFIG.OPPONENT_ID; const firstPlayerInfo = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); if (firstPlayerInfo && firstPlayerInfo.chosenCharacterKey === actualCharacterKey) { if (actualCharacterKey === 'elena') actualCharacterKey = 'almagest'; else if (actualCharacterKey === 'almagest') actualCharacterKey = 'elena'; // Добавьте другие пары, если нужно, или более общую логику выбора другого персонажа } } else { socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре.' }); return false; } } // Если для этой роли уже был игрок (например, старый сокет), удаляем его const oldPlayerSocketIdForRole = Object.keys(this.players).find(sid => this.players[sid].id === assignedPlayerId && this.players[sid].socket?.id !== socket.id); if (oldPlayerSocketIdForRole) { const oldPlayerInfo = this.players[oldPlayerSocketIdForRole]; if(oldPlayerInfo.socket) { try { oldPlayerInfo.socket.leave(this.gameId); } catch(e){} } // Убедимся, что старый сокет покинул комнату delete this.players[oldPlayerSocketIdForRole]; } this.players[socket.id] = { id: assignedPlayerId, socket: socket, chosenCharacterKey: actualCharacterKey, identifier: identifier, isTemporarilyDisconnected: false }; this.playerSockets[assignedPlayerId] = socket; this.playerCount++; socket.join(this.gameId); // Сообщаем GameInstance об установленных ключах и владельце if (assignedPlayerId === GAME_CONFIG.PLAYER_ID) this.gameInstance.setPlayerCharacterKey(actualCharacterKey); else if (assignedPlayerId === GAME_CONFIG.OPPONENT_ID) this.gameInstance.setOpponentCharacterKey(actualCharacterKey); if (!this.gameInstance.ownerIdentifier && (this.mode === 'ai' || (this.mode === 'pvp' && assignedPlayerId === GAME_CONFIG.PLAYER_ID))) { this.gameInstance.setOwnerIdentifier(identifier); } const charData = dataUtils.getCharacterData(actualCharacterKey); // Используем dataUtils напрямую console.log(`[PCH ${this.gameId}] Player ${identifier} (Socket: ${socket.id}) added as ${assignedPlayerId} with char ${charData?.baseStats?.name || actualCharacterKey}. Active players: ${this.playerCount}. Owner: ${this.gameInstance.ownerIdentifier}`); return true; } removePlayer(socketId, reason = "unknown_reason_for_removal") { const playerInfo = this.players[socketId]; if (playerInfo) { const playerRole = playerInfo.id; const playerIdentifier = playerInfo.identifier; console.log(`[PCH ${this.gameId}] Final removal of player ${playerIdentifier} (Socket: ${socketId}, Role: ${playerRole}). Reason: ${reason}.`); if (playerInfo.socket) { try { playerInfo.socket.leave(this.gameId); } catch (e) { /* ignore */ } } if (!playerInfo.isTemporarilyDisconnected) { // Уменьшаем счетчик только если это был активный игрок, а не временное отключение this.playerCount--; } delete this.players[socketId]; if (this.playerSockets[playerRole]?.id === socketId) { // Если это был текущий сокет для роли delete this.playerSockets[playerRole]; } this.clearReconnectTimer(playerRole); // Очищаем таймер переподключения для этой роли console.log(`[PCH ${this.gameId}] Player ${playerIdentifier} removed. Active players now: ${this.playerCount}.`); // Сигнализируем GameInstance, чтобы он решил, нужно ли завершать игру this.gameInstance.handlePlayerPermanentlyLeft(playerRole, playerInfo.chosenCharacterKey, reason); } else { console.warn(`[PCH ${this.gameId}] removePlayer called for unknown socketId: ${socketId}`); } } handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey) { console.log(`[PCH ${this.gameId}] handlePlayerPotentiallyLeft for role ${playerIdRole}, id ${identifier}, char ${characterKey}`); // Находим запись игрока по роли и идентификатору, так как сокет мог уже измениться или быть удален const playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); if (!playerEntry || !playerEntry.socket) { console.warn(`[PCH ${this.gameId}] No player entry or socket found for ${identifier} (role ${playerIdRole}) during potential left.`); return; } if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) { console.log(`[PCH ${this.gameId}] Game already over, not handling potential left for ${identifier}.`); return; } if (playerEntry.isTemporarilyDisconnected) { console.log(`[PCH ${this.gameId}] Player ${identifier} already marked as temp disconnected.`); return; } playerEntry.isTemporarilyDisconnected = true; this.playerCount--; // Уменьшаем счетчик активных игроков console.log(`[PCH ${this.gameId}] Player ${identifier} (role ${playerIdRole}) temp disconnected. Active: ${this.playerCount}. Starting reconnect timer.`); const disconnectedName = this.gameInstance.gameState?.[playerIdRole]?.name || characterKey || `Игрок (Роль ${playerIdRole})`; this.gameInstance.addToLog(`🔌 Игрок ${disconnectedName} отключился. Ожидание переподключения...`, GAME_CONFIG.LOG_TYPE_SYSTEM); this.gameInstance.broadcastLogUpdate(); // Уведомляем другого игрока, если он есть и подключен const otherPlayerRole = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; const otherSocket = this.playerSockets[otherPlayerRole]; // Берем сокет из нашего this.playerSockets const otherPlayerEntry = Object.values(this.players).find(p=> p.id === otherPlayerRole); if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) { otherSocket.emit('opponentDisconnected', { disconnectedPlayerId: playerIdRole, disconnectedCharacterName: disconnectedName, }); } // Приостанавливаем таймер хода, если он активен if (this.gameInstance.turnTimer.isActive() || (this.mode === 'ai' && this.gameInstance.turnTimer.isAiCurrentlyMakingMove) ) { this.pausedTurnState = this.gameInstance.turnTimer.pause(); console.log(`[PCH ${this.gameId}] Turn timer paused due to disconnect. State:`, JSON.stringify(this.pausedTurnState)); } else { this.pausedTurnState = null; // Явно сбрасываем, если таймер не был активен } this.clearReconnectTimer(playerIdRole); // Очищаем старый таймер, если был const reconnectDuration = GAME_CONFIG.RECONNECT_TIMEOUT_MS || 30000; const reconnectStartTime = Date.now(); // Таймер для обновления UI клиента const updateInterval = setInterval(() => { const remaining = reconnectDuration - (Date.now() - reconnectStartTime); if (remaining <= 0) { // Если основной таймаут уже сработал или время вышло if (this.reconnectTimers[playerIdRole]?.updateIntervalId) clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: 0 }); return; } this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: Math.ceil(remaining) }); }, 1000); // Основной таймер на окончательное удаление const timeoutId = setTimeout(() => { this.clearReconnectTimer(playerIdRole); // Очищаем таймеры (включая updateInterval) const stillDiscPlayer = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); if (stillDiscPlayer && stillDiscPlayer.isTemporarilyDisconnected) { // Передаем socket.id из записи, а не старый socketId, который мог быть от предыдущего сокета this.removePlayer(stillDiscPlayer.socket.id, "reconnect_timeout"); } }, reconnectDuration); this.reconnectTimers[playerIdRole] = { timerId: timeoutId, updateIntervalId: updateInterval, startTimeMs: reconnectStartTime, durationMs: reconnectDuration }; } handlePlayerReconnected(playerIdRole, newSocket) { const identifier = newSocket.userData?.userId; // Получаем идентификатор из нового сокета console.log(`[PCH ${this.gameId}] handlePlayerReconnected for role ${playerIdRole}, id ${identifier}, newSocket ${newSocket.id}`); if (this.gameInstance.gameState && this.gameInstance.gameState.isGameOver) { newSocket.emit('gameError', { message: 'Игра уже завершена.' }); this.gameInstance.gameManager._cleanupGame(this.gameId, `reconnect_to_finished_game_pch_${identifier}`); return false; } // Находим запись игрока по роли и идентификатору const playerEntry = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); if (playerEntry && playerEntry.isTemporarilyDisconnected) { this.clearReconnectTimer(playerIdRole); this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null }); // Сигнал, что таймер остановлен // Удаляем старую запись по socket.id, если сокет действительно новый const oldSocketId = playerEntry.socket.id; if (this.players[oldSocketId] && oldSocketId !== newSocket.id) { delete this.players[oldSocketId]; } // Обновляем запись игрока playerEntry.socket = newSocket; playerEntry.isTemporarilyDisconnected = false; this.players[newSocket.id] = playerEntry; // Добавляем/обновляем запись с новым socket.id this.playerSockets[playerIdRole] = newSocket; // Обновляем активный сокет для роли this.playerCount++; // Восстанавливаем счетчик активных игроков newSocket.join(this.gameId); const reconnectedName = this.gameInstance.gameState?.[playerIdRole]?.name || playerEntry.chosenCharacterKey; console.log(`[PCH ${this.gameId}] Player ${identifier} (${reconnectedName}) reconnected. Active: ${this.playerCount}.`); this.gameInstance.addToLog(`🔌 Игрок ${reconnectedName} снова в игре!`, GAME_CONFIG.LOG_TYPE_SYSTEM); const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey); const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; // Получаем ключ персонажа оппонента из gameState ИЛИ из предварительно сохраненных ключей в GameInstance let oCharKey = this.gameInstance.gameState?.[oppRoleKey]?.characterKey || (playerIdRole === GAME_CONFIG.PLAYER_ID ? this.gameInstance.opponentCharacterKey : this.gameInstance.playerCharacterKey); const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null; // Если gameState нет (маловероятно при реконнекте в активную игру, но возможно если это был первый игрок PvP) // GameInstance должен сам решить, нужно ли ему initializeGame() if (!this.gameInstance.gameState) { // Пытаемся инициализировать игру, если она не была инициализирована // Это важно, если первый игрок в PvP отключался до подключения второго if (!this.gameInstance.initializeGame()) { this.gameInstance._handleCriticalError('reconnect_no_gs_after_init_pch', 'PCH: GS null after re-init on reconnect.'); return false; } } newSocket.emit('gameStarted', { gameId: this.gameId, yourPlayerId: playerIdRole, initialGameState: this.gameInstance.gameState, // Отправляем текущее состояние playerBaseStats: pData?.baseStats, // Данные для этого игрока opponentBaseStats: oData?.baseStats || dataUtils.getCharacterBaseStats(null) || {name: 'Ожидание...', maxHp:1, maxResource:0, resourceName:'N/A', attackPower:0, characterKey: null}, playerAbilities: pData?.abilities, opponentAbilities: oData?.abilities || [], log: this.gameInstance.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG } // Отправляем копию конфига }); // Уведомляем другого игрока const otherSocket = this.playerSockets[oppRoleKey]; const otherPlayerEntry = Object.values(this.players).find(p=> p.id === oppRoleKey); if (otherSocket?.connected && otherPlayerEntry && !otherPlayerEntry.isTemporarilyDisconnected) { otherSocket.emit('playerReconnected', { reconnectedPlayerId: playerIdRole, reconnectedPlayerName: reconnectedName }); if (this.gameInstance.logBuffer.length > 0) { // Отправляем накопившиеся логи, если есть otherSocket.emit('logUpdate', { log: this.gameInstance.consumeLogBuffer() }); } } // Если игра не на "эффективной" паузе и не закончена, возобновляем игру if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver) { this.gameInstance.broadcastGameStateUpdate(); // Обновляем состояние для всех if (this.pausedTurnState && typeof this.pausedTurnState.remainingTime === 'number') { this.gameInstance.turnTimer.resume( this.pausedTurnState.remainingTime, this.pausedTurnState.forPlayerRoleIsPlayer, this.pausedTurnState.isAiCurrentlyMoving ); this.pausedTurnState = null; // Сбрасываем сохраненное состояние таймера } else { // Если pausedTurnState нет, значит, таймер не был активен или это первый ход // GameInstance.startGame или switchTurn должны запустить таймер корректно // Но если это реконнект в середину игры, где ход уже чей-то, нужно запустить таймер const currentTurnIsForPlayer = this.gameInstance.gameState.isPlayerTurn; const isCurrentTurnAi = this.mode === 'ai' && !currentTurnIsForPlayer; this.gameInstance.turnTimer.start(currentTurnIsForPlayer, isCurrentTurnAi); } } return true; } else if (playerEntry && !playerEntry.isTemporarilyDisconnected) { // Игрок уже был подключен и не был отмечен как isTemporarilyDisconnected // Это может быть попытка открыть игру в новой вкладке или "обновить сессию" if (playerEntry.socket.id !== newSocket.id) { newSocket.emit('gameError', {message: "Вы уже активно подключены с другой сессии."}); return false; // Не позволяем подключиться с нового сокета, если старый активен } // Если это тот же сокет (например, клиент запросил состояние), просто отправляем ему данные if (!this.gameInstance.gameState) { // На всякий случай, если gameState вдруг нет if (!this.gameInstance.initializeGame()) { this.gameInstance._handleCriticalError('reconnect_same_socket_no_gs_pch','PCH: GS null on same socket reconnect.'); return false; } } const pData = dataUtils.getCharacterData(playerEntry.chosenCharacterKey); const oppRoleKey = playerIdRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; let oCharKey = this.gameInstance.gameState?.[oppRoleKey]?.characterKey || (playerIdRole === GAME_CONFIG.PLAYER_ID ? this.gameInstance.opponentCharacterKey : this.gameInstance.playerCharacterKey); const oData = oCharKey ? dataUtils.getCharacterData(oCharKey) : null; newSocket.emit('gameStarted', { gameId: this.gameId, yourPlayerId: playerIdRole, initialGameState: this.gameInstance.gameState, playerBaseStats: pData?.baseStats, opponentBaseStats: oData?.baseStats, // Могут быть неполными, если оппонент еще не подключился playerAbilities: pData?.abilities, opponentAbilities: oData?.abilities, log: this.gameInstance.consumeLogBuffer(), clientConfig: { ...GAME_CONFIG } }); return true; } else { // Запись игрока не найдена или он не был помечен как isTemporarilyDisconnected, но сокет новый. // Это может быть попытка реконнекта к игре, из которой игрок был уже удален (например, по таймауту). newSocket.emit('gameError', { message: 'Не удалось восстановить сессию (запись игрока не найдена или сессия устарела).' }); return false; } } clearReconnectTimer(playerIdRole) { if (this.reconnectTimers[playerIdRole]) { clearTimeout(this.reconnectTimers[playerIdRole].timerId); if (this.reconnectTimers[playerIdRole].updateIntervalId) { clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); } delete this.reconnectTimers[playerIdRole]; console.log(`[PCH ${this.gameId}] Cleared reconnect timer for role ${playerIdRole}.`); } } clearAllReconnectTimers() { console.log(`[PCH ${this.gameId}] Clearing ALL reconnect timers.`); for (const roleId in this.reconnectTimers) { this.clearReconnectTimer(roleId); } } isGameEffectivelyPaused() { if (this.mode === 'pvp') { // Если игроков меньше 2, И есть хотя бы один игрок в this.players (ожидающий или в процессе дисконнекта) if (this.playerCount < 2 && Object.keys(this.players).length > 0) { // Проверяем, есть ли кто-то из них в состоянии временного дисконнекта const p1Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); const p2Entry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID); if ((p1Entry && p1Entry.isTemporarilyDisconnected) || (p2Entry && p2Entry.isTemporarilyDisconnected)) { return true; // Игра на паузе, если один из игроков временно отключен } } } else if (this.mode === 'ai') { // В AI режиме игра на паузе, если единственный человек-игрок временно отключен const humanPlayer = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); return humanPlayer?.isTemporarilyDisconnected ?? false; // Если игрока нет, не на паузе. Если есть - зависит от его состояния. } return false; // В остальных случаях игра не считается на паузе из-за дисконнектов } // Вспомогательный метод для получения информации о всех игроках (может пригодиться GameInstance) getAllPlayersInfo() { return { ...this.players }; } // Вспомогательный метод для получения сокетов (может пригодиться GameInstance) getPlayerSockets() { return { ...this.playerSockets }; } } module.exports = PlayerConnectionHandler;