diff --git a/server/game/instance/GameInstance.js b/server/game/instance/GameInstance.js index c6cd72d..f18f58f 100644 --- a/server/game/instance/GameInstance.js +++ b/server/game/instance/GameInstance.js @@ -1,6 +1,6 @@ // /server/game/instance/GameInstance.js const { v4: uuidv4 } = require('uuid'); -const TurnTimer = require('./TurnTimer'); +const TurnTimer = require('./TurnTimer'); // Убедитесь, что это новый TurnTimer.js const gameLogic = require('../logic'); const dataUtils = require('../../data/dataUtils'); const GAME_CONFIG = require('../../core/config'); @@ -27,16 +27,17 @@ class GameInstance { GAME_CONFIG.TURN_DURATION_MS, GAME_CONFIG.TIMER_UPDATE_INTERVAL_MS, () => this.handleTurnTimeout(), - (remainingTime, isPlayerTurnForTimer, isPaused) => { - // Логируем отправку обновления таймера - // console.log(`[GI TURN_TIMER_CB ${this.id}] Sending update. Remaining: ${remainingTime}, isPlayerT: ${isPlayerTurnForTimer}, isPaused (raw): ${isPaused}, effectivelyPaused: ${this.isGameEffectivelyPaused()}`); + // onTickCallback: (remainingTimeMs, isForPlayerSlotTurn_timerPerspective, isTimerEffectivelyPaused_byLogic) + (remainingTime, isPlayerTurnForTimer, isTimerLogicPaused) => { + const socketsInRoom = Array.from(this.io.sockets.adapter.rooms.get(this.id) || []); + console.log(`[GI TURN_TIMER_UPDATE_CB ${this.id}] Called! To room ${this.id} (sockets: ${socketsInRoom.join(', ')}). Remaining: ${remainingTime}, isPlayerT_forTimer: ${isPlayerTurnForTimer}, isTimerLogicPaused: ${isTimerLogicPaused}, isGameEffectivelyPaused(GI): ${this.isGameEffectivelyPaused()}`); this.io.to(this.id).emit('turnTimerUpdate', { remainingTime, - isPlayerTurn: isPlayerTurnForTimer, - isPaused: isPaused || this.isGameEffectivelyPaused() + isPlayerTurn: isPlayerTurnForTimer, // Чей ход с точки зрения таймера + isPaused: isTimerLogicPaused || this.isGameEffectivelyPaused() // Общая пауза }); }, - this.id + this.id // gameIdForLogs ); if (!this.gameManager || typeof this.gameManager._cleanupGame !== 'function') { @@ -61,8 +62,8 @@ class GameInstance { return this.playerConnectionHandler.addPlayer(socket, chosenCharacterKey, identifier); } - removePlayer(socketId, reason) { - this.playerConnectionHandler.removePlayer(socketId, reason); + removePlayer(socketId, reason) { // Вызывается из PCH + // PCH сам обрабатывает удаление, GameInstance реагирует через handlePlayerPermanentlyLeft } handlePlayerPotentiallyLeft(playerIdRole, identifier, characterKey, disconnectedSocketId) { @@ -78,7 +79,7 @@ class GameInstance { this.playerConnectionHandler.clearAllReconnectTimers(); } - isGameEffectivelyPaused() { + isGameEffectivelyPaused() { // Определяет, приостановлена ли игра из-за дисконнектов return this.playerConnectionHandler.isGameEffectivelyPaused(); } @@ -88,6 +89,7 @@ class GameInstance { if (this.mode === 'ai' && playerRole === GAME_CONFIG.PLAYER_ID) { this.endGameDueToDisconnect(playerRole, characterKey, "player_left_ai_game"); } else if (this.mode === 'pvp') { + // playerCount уже должен быть обновлен в PCH if (this.playerCount < 2) { const remainingActivePlayerEntry = Object.values(this.players).find(p => p.id !== playerRole && !p.isTemporarilyDisconnected); this.endGameDueToDisconnect(playerRole, characterKey, "opponent_left_pvp_game", remainingActivePlayerEntry?.id); @@ -147,16 +149,13 @@ class GameInstance { const p1ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID && !p.isTemporarilyDisconnected); const p2ActiveEntry = Object.values(this.players).find(p => p.id === GAME_CONFIG.OPPONENT_ID && !p.isTemporarilyDisconnected); - // Устанавливаем ключи персонажей, если они еще не установлены, на основе активных игроков в PCH - // Это важно, если initializeGame вызывается до того, как PCH успел обновить ключи в GI через сеттеры if (p1ActiveEntry && !this.playerCharacterKey) this.playerCharacterKey = p1ActiveEntry.chosenCharacterKey; if (p2ActiveEntry && !this.opponentCharacterKey && this.mode === 'pvp') this.opponentCharacterKey = p2ActiveEntry.chosenCharacterKey; - if (this.mode === 'ai') { if (!p1ActiveEntry) { this._handleCriticalError('init_ai_no_active_player_gi', 'Инициализация AI игры: Игрок-человек не найден или не активен.'); return false; } if (!this.playerCharacterKey) { this._handleCriticalError('init_ai_no_player_key_gi', 'Инициализация AI игры: Ключ персонажа игрока не установлен.'); return false;} - this.opponentCharacterKey = 'balard'; + if (!this.opponentCharacterKey) this.opponentCharacterKey = 'balard'; // Устанавливаем AI, если еще не установлен } else { // pvp if (this.playerCount === 1 && p1ActiveEntry && !this.playerCharacterKey) { this._handleCriticalError('init_pvp_single_player_no_key_gi', 'PvP инициализация (1 игрок): Ключ персонажа игрока отсутствует.'); return false; @@ -179,7 +178,6 @@ class GameInstance { this.logBuffer = []; - // Имена берутся из playerData/opponentData, если они есть. PCH обновит их при реконнекте, если они изменились. const playerName = playerData?.baseStats?.name || (p1ActiveEntry?.name || 'Ожидание Игрока 1...'); let opponentName; if (this.mode === 'ai') { @@ -188,13 +186,12 @@ class GameInstance { opponentName = opponentData?.baseStats?.name || (p2ActiveEntry?.name || 'Ожидание Игрока 2...'); } - this.gameState = { player: isPlayerSlotFilledAndActive ? - this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities, playerName) : // Передаем имя + this._createFighterState(GAME_CONFIG.PLAYER_ID, playerData.baseStats, playerData.abilities, playerName) : this._createFighterState(GAME_CONFIG.PLAYER_ID, { name: playerName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], playerName), opponent: isOpponentSlotFilledAndActive ? - this._createFighterState(GAME_CONFIG.OPPONENT_ID, opponentData.baseStats, opponentData.abilities, opponentName) : // Передаем имя + this._createFighterState(GAME_CONFIG.OPPONENT_ID, opponentData.baseStats, opponentData.abilities, opponentName) : this._createFighterState(GAME_CONFIG.OPPONENT_ID, { name: opponentName, maxHp: 1, maxResource: 0, resourceName: 'N/A', attackPower: 0, characterKey: null }, [], opponentName), isPlayerTurn: (isPlayerSlotFilledAndActive && isOpponentSlotFilledAndActive) ? (Math.random() < 0.5) : true, isGameOver: false, @@ -207,7 +204,7 @@ class GameInstance { _createFighterState(roleId, baseStats, abilities, explicitName = null) { const fighterState = { - id: roleId, characterKey: baseStats.characterKey, name: explicitName || baseStats.name, // Используем explicitName если передано + id: roleId, characterKey: baseStats.characterKey, name: explicitName || baseStats.name, currentHp: baseStats.maxHp, maxHp: baseStats.maxHp, currentResource: baseStats.maxResource, maxResource: baseStats.maxResource, resourceName: baseStats.resourceName, attackPower: baseStats.attackPower, @@ -234,7 +231,7 @@ class GameInstance { if (!this.gameState || !this.gameState.player?.characterKey || !this.gameState.opponent?.characterKey) { console.warn(`[GameInstance ${this.id}] startGame: gameState или ключи персонажей не полностью инициализированы. Попытка повторной инициализации.`); - if (!this.initializeGame() || !this.gameState?.player?.characterKey || !this.gameState?.opponent?.characterKey) { + if (!this.initializeGame() || !this.gameState?.player?.characterKey || !this.gameState?.opponent?.characterKey) { // initializeGame сама установит gameState this._handleCriticalError('start_game_reinit_failed_sg_gi', 'Повторная инициализация перед стартом не удалась или ключи все еще отсутствуют в gameState.'); return; } @@ -249,12 +246,9 @@ class GameInstance { return; } - // Обновляем имена в gameState на основе данных персонажей перед отправкой клиентам - // Это гарантирует, что имена из dataUtils (самые "правильные") попадут в первое gameStarted if (this.gameState.player && pData?.baseStats?.name) this.gameState.player.name = pData.baseStats.name; if (this.gameState.opponent && oData?.baseStats?.name) this.gameState.opponent.name = oData.baseStats.name; - this.addToLog('⚔️ Новая битва начинается! ⚔️', GAME_CONFIG.LOG_TYPE_SYSTEM); if(this.gameState.player?.characterKey && this.gameState.opponent?.characterKey) { @@ -288,7 +282,7 @@ class GameInstance { this.broadcastLogUpdate(); const isFirstTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn; - console.log(`[GameInstance ${this.id}] Запуск таймера в startGame. isPlayerTurn: ${this.gameState.isPlayerTurn}, isFirstTurnAi: ${isFirstTurnAi}`); + console.log(`[GameInstance ${this.id}] Запуск таймера в startGame. isPlayerTurn(GS): ${this.gameState.isPlayerTurn}, isAiMakingMove(to timer): ${isFirstTurnAi}`); this.turnTimer.start(this.gameState.isPlayerTurn, isFirstTurnAi); if (isFirstTurnAi) { @@ -311,7 +305,10 @@ class GameInstance { actingPlayerInfo.socket.emit('gameError', {message: "Действие невозможно: игра на паузе."}); return; } - if (!this.gameState || this.gameState.isGameOver) { return; } + if (!this.gameState || this.gameState.isGameOver) { + console.warn(`[GameInstance ${this.id}] processPlayerAction: Действие от ${identifier} проигнорировано (нет gameState или игра завершена). GameOver: ${this.gameState?.isGameOver}`); + return; + } const actingPlayerRole = actingPlayerInfo.id; const isCorrectTurn = (this.gameState.isPlayerTurn && actingPlayerRole === GAME_CONFIG.PLAYER_ID) || @@ -323,8 +320,11 @@ class GameInstance { return; } - console.log(`[GameInstance ${this.id}] Ход корректен. Очистка таймера.`); - if(this.turnTimer.isActive()) this.turnTimer.clear(); + console.log(`[GameInstance ${this.id}] Ход корректен для ${identifier}. Очистка таймера.`); + if(this.turnTimer.isActive() || this.turnTimer.isPaused()) { // Очищаем, даже если на паузе, т.к. действие совершено + this.turnTimer.clear(); + } + const attackerState = this.gameState[actingPlayerRole]; const defenderRole = actingPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; @@ -365,12 +365,14 @@ class GameInstance { } if (this.checkGameOver()) return; - this.broadcastLogUpdate(); + this.broadcastLogUpdate(); // Отправляем лог сразу после действия if (actionIsValidAndPerformed) { + // Небольшая задержка перед сменой хода, чтобы клиент успел увидеть результат действия setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); } else { + // Если действие было невалидным, перезапускаем таймер для текущего игрока const isAiTurnForTimer = this.mode === 'ai' && !this.gameState.isPlayerTurn; - console.log(`[GameInstance ${this.id}] Действие не выполнено, перезапуск таймера. isPlayerTurn: ${this.gameState.isPlayerTurn}, isAiTurnForTimer: ${isAiTurnForTimer}`); + console.log(`[GameInstance ${this.id}] Действие не выполнено, перезапуск таймера для ${identifier}. isPlayerTurn(GS): ${this.gameState.isPlayerTurn}, isAiMakingMove(to timer): ${isAiTurnForTimer}`); this.turnTimer.start(this.gameState.isPlayerTurn, isAiTurnForTimer); } } @@ -379,7 +381,12 @@ class GameInstance { console.log(`[GameInstance ${this.id}] Попытка смены хода. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}`); if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Смена хода отложена: игра на паузе.`); return; } if (!this.gameState || this.gameState.isGameOver) { return; } - if(this.turnTimer.isActive()) this.turnTimer.clear(); + + // Таймер хода должен быть уже очищен в processPlayerAction или processAiTurn + // Но на всякий случай, если switchTurn вызван из другого места (например, после эффектов) + if(this.turnTimer.isActive() || this.turnTimer.isPaused()) { + this.turnTimer.clear(); + } const endingTurnActorRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; const endingTurnActorState = this.gameState[endingTurnActorRole]; @@ -403,14 +410,14 @@ class GameInstance { if (!currentTurnActorState || !currentTurnActorState.name) { this._handleCriticalError('switch_turn_current_actor_invalid_gi', `Состояние или имя текущего актора недействительны.`); return; } this.addToLog(`--- Ход ${this.gameState.turnNumber} начинается для: ${currentTurnActorState.name} ---`, GAME_CONFIG.LOG_TYPE_TURN); - this.broadcastGameStateUpdate(); + this.broadcastGameStateUpdate(); // Отправляем обновленное состояние и все накопленные логи const currentTurnPlayerEntry = Object.values(this.players).find(p => p.id === currentTurnActorRole); if (currentTurnPlayerEntry && currentTurnPlayerEntry.isTemporarilyDisconnected) { console.log(`[GameInstance ${this.id}] Ход перешел к ${currentTurnActorRole}, но игрок ${currentTurnPlayerEntry.identifier} отключен. Таймер не запущен switchTurn.`); } else { const isNextTurnAi = this.mode === 'ai' && !this.gameState.isPlayerTurn; - console.log(`[GameInstance ${this.id}] Запуск таймера в switchTurn. isPlayerTurn: ${this.gameState.isPlayerTurn}, isNextTurnAi: ${isNextTurnAi}`); + console.log(`[GameInstance ${this.id}] Запуск таймера в switchTurn. isPlayerTurn(GS): ${this.gameState.isPlayerTurn}, isAiMakingMove(to timer): ${isNextTurnAi}`); this.turnTimer.start(this.gameState.isPlayerTurn, isNextTurnAi); if (isNextTurnAi) { setTimeout(() => { @@ -423,7 +430,7 @@ class GameInstance { } processAiTurn() { - console.log(`[GameInstance ${this.id}] processAiTurn. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}, IsPlayerTurn: ${this.gameState?.isPlayerTurn}`); + console.log(`[GameInstance ${this.id}] processAiTurn. Paused: ${this.isGameEffectivelyPaused()}, GameOver: ${this.gameState?.isGameOver}, IsPlayerTurn(GS): ${this.gameState?.isPlayerTurn}`); if (this.isGameEffectivelyPaused()) { console.log(`[GameInstance ${this.id}] Ход AI отложен: игра на паузе.`); return; } if (!this.gameState || this.gameState.isGameOver || this.gameState.isPlayerTurn || !this.aiOpponent) { return; } if(this.gameState.opponent?.characterKey !== 'balard' && this.aiOpponent) { @@ -431,7 +438,10 @@ class GameInstance { this.switchTurn(); return; } - if(this.turnTimer.isActive()) this.turnTimer.clear(); + + if(this.turnTimer.isActive() || this.turnTimer.isPaused()) { // Очищаем таймер, так как AI сейчас сделает ход + this.turnTimer.clear(); + } const aiState = this.gameState.opponent; const playerState = this.gameState.player; @@ -457,7 +467,7 @@ class GameInstance { } if (this.checkGameOver()) return; - this.broadcastLogUpdate(); + this.broadcastLogUpdate(); // Отправляем лог после действия AI if (actionIsValidAndPerformedForAI) { setTimeout(() => this.switchTurn(), GAME_CONFIG.DELAY_AFTER_PLAYER_ACTION); } else { @@ -486,7 +496,7 @@ class GameInstance { const gameOverResult = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode); if (gameOverResult.isOver) { this.gameState.isGameOver = true; - if(this.turnTimer.isActive()) this.turnTimer.clear(); + if(this.turnTimer.isActive() || this.turnTimer.isPaused()) this.turnTimer.clear(); // Очищаем таймер, если игра окончена this.clearAllReconnectTimers(); this.addToLog(gameOverResult.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); @@ -513,7 +523,7 @@ class GameInstance { endGameDueToDisconnect(disconnectedPlayerRole, disconnectedCharacterKey, reason = "opponent_disconnected", winnerIfAny = null) { if (this.gameState && !this.gameState.isGameOver) { this.gameState.isGameOver = true; - if(this.turnTimer.isActive()) this.turnTimer.clear(); + if(this.turnTimer.isActive() || this.turnTimer.isPaused()) this.turnTimer.clear(); this.clearAllReconnectTimers(); let actualWinnerRole = winnerIfAny; @@ -583,7 +593,7 @@ class GameInstance { this.addToLog(`Игрок покинул AI игру до ее полного начала.`, GAME_CONFIG.LOG_TYPE_SYSTEM); } - if (this.turnTimer.isActive()) this.turnTimer.clear(); + if (this.turnTimer.isActive() || this.turnTimer.isPaused()) this.turnTimer.clear(); this.clearAllReconnectTimers(); this.io.to(this.id).emit('gameOver', { @@ -634,7 +644,7 @@ class GameInstance { const winnerCharKey = this.gameState[winnerRole]?.characterKey; this.gameState.isGameOver = true; - if(this.turnTimer.isActive()) this.turnTimer.clear(); + if(this.turnTimer.isActive() || this.turnTimer.isPaused()) this.turnTimer.clear(); this.clearAllReconnectTimers(); this.addToLog(`🏳️ ${surrenderedPlayerName} сдался! ${winnerName} объявляется победителем!`, GAME_CONFIG.LOG_TYPE_SYSTEM); @@ -654,8 +664,10 @@ class GameInstance { handleTurnTimeout() { if (!this.gameState || this.gameState.isGameOver) return; - console.log(`[GameInstance ${this.id}] Произошел таймаут хода.`); - const timedOutPlayerRole = this.gameState.isPlayerTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; + console.log(`[GameInstance ${this.id}] Произошел таймаут хода (вызван из TurnTimer).`); + const timedOutPlayerRole = this.turnTimer.isConfiguredForPlayerSlotTurn ? GAME_CONFIG.PLAYER_ID : GAME_CONFIG.OPPONENT_ID; + // Используем isConfiguredForPlayerSlotTurn из таймера, т.к. gameState.isPlayerTurn мог измениться до фактического вызова этого коллбэка + // или если таймаут произошел во время "думания" AI (хотя таймер AI не должен вызывать этот коллбэк для игрока). const winnerPlayerRoleIfHuman = timedOutPlayerRole === GAME_CONFIG.PLAYER_ID ? GAME_CONFIG.OPPONENT_ID : GAME_CONFIG.PLAYER_ID; let winnerActuallyExists = false; @@ -670,6 +682,7 @@ class GameInstance { const result = gameLogic.getGameOverResult(this.gameState, GAME_CONFIG, this.mode, 'turn_timeout', winnerActuallyExists ? winnerPlayerRoleIfHuman : null, timedOutPlayerRole); this.gameState.isGameOver = true; + // turnTimer.clear() уже должен был быть вызван внутри TurnTimer перед onTimeoutCallback, или будет вызван в checkGameOver this.clearAllReconnectTimers(); this.addToLog(result.logMessage, GAME_CONFIG.LOG_TYPE_SYSTEM); @@ -694,7 +707,7 @@ class GameInstance { this.gameState = { isGameOver: true, player: {}, opponent: {}, turnNumber: 0, gameMode: this.mode }; } - if(this.turnTimer.isActive()) this.turnTimer.clear(); + if(this.turnTimer.isActive() || this.turnTimer.isPaused()) this.turnTimer.clear(); this.clearAllReconnectTimers(); this.addToLog(`Критическая ошибка сервера: ${logMessage}. Игра будет завершена.`, GAME_CONFIG.LOG_TYPE_SYSTEM); @@ -711,8 +724,6 @@ class GameInstance { addToLog(message, type = GAME_CONFIG.LOG_TYPE_INFO) { if (!message) return; this.logBuffer.push({ message, type, timestamp: Date.now() }); - // Раскомментируйте для немедленной отправки логов, если нужно (но обычно лучше батчинг) - // this.broadcastLogUpdate(); } consumeLogBuffer() { @@ -730,7 +741,7 @@ class GameInstance { console.warn(`[GameInstance ${this.id}] broadcastGameStateUpdate: gameState отсутствует.`); return; } - console.log(`[GameInstance ${this.id}] Отправка gameStateUpdate. IsPlayerTurn: ${this.gameState.isPlayerTurn}`); + console.log(`[GameInstance ${this.id}] Отправка gameStateUpdate. IsPlayerTurn(GS): ${this.gameState.isPlayerTurn}`); this.io.to(this.id).emit('gameStateUpdate', { gameState: this.gameState, log: this.consumeLogBuffer() }); } @@ -740,7 +751,7 @@ class GameInstance { if (systemLogs.length > 0) { this.io.to(this.id).emit('logUpdate', { log: systemLogs }); } - this.logBuffer = this.logBuffer.filter(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM); // Оставляем несистемные + this.logBuffer = this.logBuffer.filter(log => log.type !== GAME_CONFIG.LOG_TYPE_SYSTEM); return; } if (this.logBuffer.length > 0) { diff --git a/server/game/instance/PlayerConnectionHandler.js b/server/game/instance/PlayerConnectionHandler.js index 1b15d06..0f27c1e 100644 --- a/server/game/instance/PlayerConnectionHandler.js +++ b/server/game/instance/PlayerConnectionHandler.js @@ -29,31 +29,25 @@ class PlayerConnectionHandler { socket.emit('gameError', { message: 'Эта игра уже завершена.' }); return false; } - // Если игрок уже есть, и это не временное отключение, и сокет другой - это F5 или новая вкладка. - // GameManager должен был направить на handleRequestGameState, который вызовет handlePlayerReconnected. - // Прямой addPlayer в этом случае - редкий сценарий, но handlePlayerReconnected его обработает. + // Делегируем handlePlayerReconnected, который разберется, новый ли это сокет или тот же. return this.handlePlayerReconnected(existingPlayerByIdentifier.id, socket); } - if (Object.keys(this.players).length >= 2 && this.playerCount >=2 && this.mode === 'pvp') { // В AI режиме только 1 человек - socket.emit('gameError', { message: 'Эта игра уже заполнена.' }); + // Проверка на максимальное количество игроков + if (this.mode === 'pvp' && this.playerCount >= 2) { + socket.emit('gameError', { message: 'Эта PvP игра уже заполнена.' }); return false; } - if (this.mode === 'ai' && this.playerCount >=1) { + if (this.mode === 'ai' && this.playerCount >= 1) { socket.emit('gameError', { message: 'К AI игре может присоединиться только один игрок.'}); return false; } - let assignedPlayerId; let actualCharacterKey = chosenCharacterKey || 'elena'; - const charData = dataUtils.getCharacterData(actualCharacterKey); + const charDataForName = dataUtils.getCharacterData(actualCharacterKey); // Для имени if (this.mode === 'ai') { - // if (this.playerSockets[GAME_CONFIG.PLAYER_ID]) { // Эта проверка уже покрыта playerCount >= 1 выше - // socket.emit('gameError', { message: 'Нельзя присоединиться к AI игре как второй игрок.' }); - // return false; - // } assignedPlayerId = GAME_CONFIG.PLAYER_ID; } else { // pvp if (!this.playerSockets[GAME_CONFIG.PLAYER_ID]) { @@ -62,39 +56,53 @@ class PlayerConnectionHandler { 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 actualCharacterKey = dataUtils.getAllCharacterKeys().find(k => k !== firstPlayerInfo.chosenCharacterKey) || 'elena'; + const allKeys = dataUtils.getAllCharacterKeys ? dataUtils.getAllCharacterKeys() : ['elena', 'almagest', 'balard']; + const otherKey = allKeys.find(k => k !== firstPlayerInfo.chosenCharacterKey && k !== 'balard'); // Не даем Баларда второму игроку по умолчанию + actualCharacterKey = otherKey || (actualCharacterKey === 'elena' ? 'almagest' : 'elena'); // Фоллбэк } - } else { // Оба слота заняты, но playerCount мог быть < 2 если кто-то в процессе дисконнекта - socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре (возможно, все заняты или в процессе переподключения).' }); + } else { + socket.emit('gameError', { message: 'Не удалось найти свободный слот в PvP игре.' }); return false; } } - // Если для этой роли УЖЕ был игрок (например, старый сокет при F5 до того, как сработал disconnect), - // то handlePlayerReconnected должен был бы это обработать. Этот блок здесь - подстраховка, - // если addPlayer вызван напрямую в таком редком случае. - 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]; - console.warn(`[PCH ${this.gameId}] addPlayer: Найден старый сокет ${oldPlayerInfo.socket?.id} для роли ${assignedPlayerId}. Удаляем его запись.`); - if(oldPlayerInfo.socket) { try { oldPlayerInfo.socket.leave(this.gameId); oldPlayerInfo.socket.disconnect(true); } catch(e){} } - delete this.players[oldPlayerSocketIdForRole]; + // Удаление старой записи, если сокет для этой роли уже существует, но с другим ID + // (на случай очень быстрой смены сокета до срабатывания disconnect) + const oldPlayerSocketEntry = Object.entries(this.players).find(([sid, pInfo]) => pInfo.id === assignedPlayerId); + if (oldPlayerSocketEntry) { + const [oldSocketId, oldPlayerInfo] = oldPlayerSocketEntry; + if (oldPlayerInfo.socket && oldPlayerInfo.socket.id !== socket.id) { + console.warn(`[PCH ${this.gameId}] addPlayer: Найдена старая запись для роли ${assignedPlayerId} с сокетом ${oldPlayerInfo.socket.id}. Новый сокет: ${socket.id}. Удаляем старую запись.`); + try { + if (oldPlayerInfo.socket.connected) oldPlayerInfo.socket.disconnect(true); + } catch (e) { console.error(`[PCH ${this.gameId}] Ошибка при дисконнекте старого сокета: ${e.message}`); } + delete this.players[oldSocketId]; + if (this.playerSockets[assignedPlayerId] === oldPlayerInfo.socket) { + delete this.playerSockets[assignedPlayerId]; + } + // Не уменьшаем playerCount здесь, так как это замена, а не уход + } } + this.players[socket.id] = { id: assignedPlayerId, socket: socket, chosenCharacterKey: actualCharacterKey, identifier: identifier, isTemporarilyDisconnected: false, - name: charData?.baseStats?.name || actualCharacterKey + name: charDataForName?.baseStats?.name || actualCharacterKey }; this.playerSockets[assignedPlayerId] = socket; - this.playerCount++; - socket.join(this.gameId); - console.log(`[PCH ${this.gameId}] Сокет ${socket.id} присоединен к комнате ${this.gameId} (addPlayer).`); + this.playerCount++; // Увеличиваем счетчик активных игроков + + try { + socket.join(this.gameId); + console.log(`[PCH ${this.gameId}] Сокет ${socket.id} присоединен к комнате ${this.gameId} (addPlayer).`); + } catch (e) { + console.error(`[PCH ${this.gameId}] КРИТИЧЕСКАЯ ОШИБКА при socket.join: ${e.message}. Игрок ${identifier} может не получать широковещательные сообщения.`); + // Возможно, стоит откатить добавление игрока или вернуть false + } if (assignedPlayerId === GAME_CONFIG.PLAYER_ID) this.gameInstance.setPlayerCharacterKey(actualCharacterKey); @@ -116,7 +124,7 @@ class PlayerConnectionHandler { console.log(`[PCH ${this.gameId}] Окончательное удаление игрока ${playerIdentifier} (Socket: ${socketId}, Role: ${playerRole}). Причина: ${reason}.`); if (playerInfo.socket) { - try { playerInfo.socket.leave(this.gameId); } catch (e) { console.warn(`[PCH ${this.gameId}] Ошибка при playerInfo.socket.leave: ${e.message}`); } + try { playerInfo.socket.leave(this.gameId); } catch (e) { console.warn(`[PCH ${this.gameId}] Ошибка при playerInfo.socket.leave в removePlayer: ${e.message}`); } } if (!playerInfo.isTemporarilyDisconnected) { @@ -143,19 +151,17 @@ class PlayerConnectionHandler { if (!playerEntry || !playerEntry.socket) { console.warn(`[PCH ${this.gameId}] Запись игрока или сокет не найдены для ${identifier} (роль ${playerIdRole}) во время потенциального выхода. disconnectedSocketId: ${disconnectedSocketId}`); - // Если записи нет, возможно, игрок уже удален или это был очень старый сокет. - // Проверим, есть ли запись по disconnectedSocketId, и если да, удалим ее. if (this.players[disconnectedSocketId]) { - console.warn(`[PCH ${this.gameId}] Найдена запись по disconnectedSocketId ${disconnectedSocketId}, удаляем ее.`); - this.removePlayer(disconnectedSocketId, 'stale_socket_disconnect_no_entry'); + console.warn(`[PCH ${this.gameId}] Найдена запись по disconnectedSocketId ${disconnectedSocketId} (без playerEntry по роли/id), удаляем ее.`); + this.removePlayer(disconnectedSocketId, 'stale_socket_disconnect_no_main_entry'); } return; } if (playerEntry.socket.id !== disconnectedSocketId) { - console.log(`[PCH ${this.gameId}] Событие отключения для УСТАРЕВШЕГО сокета ${disconnectedSocketId} для игрока ${identifier} (Роль ${playerIdRole}). Текущий активный сокет: ${playerEntry.socket.id}. Игрок, вероятно, уже переподключился или сессия обновлена. Игнорируем дальнейшую логику "потенциального выхода" для этого устаревшего сокета.`); + console.log(`[PCH ${this.gameId}] Событие отключения для УСТАРЕВШЕГО сокета ${disconnectedSocketId} для игрока ${identifier} (Роль ${playerIdRole}). Текущий активный сокет: ${playerEntry.socket.id}. Игнорируем.`); if (this.players[disconnectedSocketId]) { - delete this.players[disconnectedSocketId]; // Удаляем только эту запись, не вызываем полный removePlayer + delete this.players[disconnectedSocketId]; } return; } @@ -170,7 +176,7 @@ class PlayerConnectionHandler { } playerEntry.isTemporarilyDisconnected = true; - this.playerCount--; + this.playerCount--; // Уменьшаем счетчик активных console.log(`[PCH ${this.gameId}] Игрок ${identifier} (роль ${playerIdRole}, сокет ${disconnectedSocketId}) временно отключен. Активных: ${this.playerCount}. Запускаем таймер переподключения.`); const disconnectedName = playerEntry.name || this.gameInstance.gameState?.[playerIdRole]?.name || characterKey || `Игрок (Роль ${playerIdRole})`; @@ -188,22 +194,30 @@ class PlayerConnectionHandler { }); } - if (this.gameInstance.turnTimer && (this.gameInstance.turnTimer.isActive() || (this.mode === 'ai' && this.gameInstance.turnTimer.isConfiguredForAiMove))) { + if (this.gameInstance.turnTimer && (this.gameInstance.turnTimer.isActive() || this.gameInstance.turnTimer.getIsConfiguredForAiMove?.())) { this.pausedTurnState = this.gameInstance.turnTimer.pause(); console.log(`[PCH ${this.gameId}] Таймер хода приостановлен из-за отключения. Состояние:`, JSON.stringify(this.pausedTurnState)); } else { this.pausedTurnState = null; } - this.clearReconnectTimer(playerIdRole); + this.clearReconnectTimer(playerIdRole); // Очищаем старый, если был const reconnectDuration = GAME_CONFIG.RECONNECT_TIMEOUT_MS || 30000; const reconnectStartTime = Date.now(); const updateInterval = setInterval(() => { + const timerInfo = this.reconnectTimers[playerIdRole]; + if (!timerInfo || timerInfo.timerId === null) { // Если основной таймер уже сработал/очищен + if (timerInfo?.updateIntervalId) clearInterval(timerInfo.updateIntervalId); + if (timerInfo) timerInfo.updateIntervalId = null; + this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: 0 }); + return; + } const remaining = reconnectDuration - (Date.now() - reconnectStartTime); - if (remaining <= 0 || !this.reconnectTimers[playerIdRole] || this.reconnectTimers[playerIdRole]?.timerId === null) { // Добавлена проверка на существование таймера - if (this.reconnectTimers[playerIdRole]?.updateIntervalId) clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); - if (this.reconnectTimers[playerIdRole]) this.reconnectTimers[playerIdRole].updateIntervalId = null; // Помечаем, что интервал очищен + if (remaining <= 0) { + // Даем основному setTimeout сработать, здесь просто останавливаем интервал тиков + clearInterval(timerInfo.updateIntervalId); + timerInfo.updateIntervalId = null; this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: 0 }); return; } @@ -211,15 +225,24 @@ class PlayerConnectionHandler { }, 1000); const timeoutId = setTimeout(() => { - if (this.reconnectTimers[playerIdRole]?.updateIntervalId) { // Очищаем интервал, если он еще существует - clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); - this.reconnectTimers[playerIdRole].updateIntervalId = null; + const timerInfo = this.reconnectTimers[playerIdRole]; + if (timerInfo?.updateIntervalId) { + clearInterval(timerInfo.updateIntervalId); + timerInfo.updateIntervalId = null; } - this.reconnectTimers[playerIdRole].timerId = null; // Помечаем, что основной таймаут сработал или очищен + if (timerInfo) timerInfo.timerId = null; // Помечаем, что сработал + + // this.clearReconnectTimer(playerIdRole) здесь вызовет сам себя рекурсивно, если удалить delete this.reconnectTimers[playerIdRole]; + // Поэтому просто удаляем запись, т.к. таймеры уже очищены или помечены. + if (this.reconnectTimers[playerIdRole]) delete this.reconnectTimers[playerIdRole]; + const stillDiscPlayer = Object.values(this.players).find(p => p.id === playerIdRole && p.identifier === identifier); if (stillDiscPlayer && stillDiscPlayer.isTemporarilyDisconnected) { + console.log(`[PCH ${this.gameId}] Таймаут переподключения для ${identifier}. Удаляем игрока.`); this.removePlayer(stillDiscPlayer.socket.id, "reconnect_timeout"); + } else { + console.log(`[PCH ${this.gameId}] Таймаут переподключения для ${identifier}, но игрок уже не (или не был) isTemporarilyDisconnected.`); } }, reconnectDuration); this.reconnectTimers[playerIdRole] = { timerId: timeoutId, updateIntervalId: updateInterval, startTimeMs: reconnectStartTime, durationMs: reconnectDuration }; @@ -239,41 +262,41 @@ class PlayerConnectionHandler { if (playerEntry) { const oldSocket = playerEntry.socket; + const wasTemporarilyDisconnected = playerEntry.isTemporarilyDisconnected; - // Обновляем сокет в playerEntry и в this.players / this.playerSockets, если сокет новый if (oldSocket && oldSocket.id !== newSocket.id) { - console.log(`[PCH ${this.gameId}] New socket ${newSocket.id} for player ${identifier}. Old socket: ${oldSocket.id}. Updating records.`); - if (this.players[oldSocket.id]) delete this.players[oldSocket.id]; // Удаляем старую запись по старому socket.id - if (oldSocket.connected) { // Пытаемся корректно закрыть старый сокет - console.log(`[PCH ${this.gameId}] Disconnecting old stale socket ${oldSocket.id}.`); + console.log(`[PCH ${this.gameId}] Новый сокет ${newSocket.id} для игрока ${identifier}. Старый сокет: ${oldSocket.id}. Обновляем записи.`); + if (this.players[oldSocket.id]) delete this.players[oldSocket.id]; + if (oldSocket.connected) { + console.log(`[PCH ${this.gameId}] Отключаем старый "подвисший" сокет ${oldSocket.id}.`); oldSocket.disconnect(true); } } - playerEntry.socket = newSocket; // Обновляем сокет в существующей playerEntry - this.players[newSocket.id] = playerEntry; // Убеждаемся, что по новому ID есть актуальная запись + playerEntry.socket = newSocket; + this.players[newSocket.id] = playerEntry; // Обновляем/добавляем запись с новым socket.id + // Если старый ID был ключом для playerEntry, и он не равен newSocket.id, удаляем старый ключ if (oldSocket && oldSocket.id !== newSocket.id && this.players[oldSocket.id] === playerEntry) { - // Если вдруг playerEntry был взят по старому socket.id, и этот ID теперь должен быть удален delete this.players[oldSocket.id]; } - this.playerSockets[playerIdRole] = newSocket; // Обновляем авторитетный сокет для роли + this.playerSockets[playerIdRole] = newSocket; - // Всегда заново присоединяем сокет к комнате - console.log(`[PCH ${this.gameId}] Forcing newSocket ${newSocket.id} (identifier: ${identifier}) to join room ${this.gameId} during reconnect.`); - newSocket.join(this.gameId); + try { + newSocket.join(this.gameId); + console.log(`[PCH ${this.gameId}] Сокет ${newSocket.id} (identifier: ${identifier}) присоединен/переприсоединен к комнате ${this.gameId} (handlePlayerReconnected).`); + } catch (e) { + console.error(`[PCH ${this.gameId}] КРИТИЧЕСКАЯ ОШИБКА при newSocket.join в handlePlayerReconnected: ${e.message}.`); + } - if (playerEntry.isTemporarilyDisconnected) { + if (wasTemporarilyDisconnected) { console.log(`[PCH ${this.gameId}] Переподключение игрока ${identifier} (Роль: ${playerIdRole}), который был временно отключен.`); - this.clearReconnectTimer(playerIdRole); // Очищаем таймер реконнекта - this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null }); // Сообщаем UI, что таймер остановлен + this.clearReconnectTimer(playerIdRole); + this.io.to(this.gameId).emit('reconnectTimerUpdate', { disconnectingPlayerId: playerIdRole, remainingTime: null }); playerEntry.isTemporarilyDisconnected = false; - this.playerCount++; // Восстанавливаем счетчик активных игроков + this.playerCount++; } else { - // Игрок не был помечен как временно отключенный. - // Это может быть F5 или запрос состояния на "том же" (или новом, но старый не отвалился) сокете. - // playerCount не меняется, т.к. игрок считался активным. - console.log(`[PCH ${this.gameId}] Игрок ${identifier} (Роль: ${playerIdRole}) переподключился/запросил состояние, не будучи помеченным как 'temporarilyDisconnected'. Old socket ID: ${oldSocket?.id}`); + console.log(`[PCH ${this.gameId}] Игрок ${identifier} (Роль: ${playerIdRole}) переподключился/запросил состояние, не будучи помеченным как 'temporarilyDisconnected'. Старый сокет ID: ${oldSocket?.id}, Новый сокет ID: ${newSocket.id}`); } // Обновление имени @@ -288,28 +311,38 @@ class PlayerConnectionHandler { this.gameInstance.addToLog(`🔌 Игрок ${playerEntry.name || identifier} снова в игре! (Сессия обновлена)`, GAME_CONFIG.LOG_TYPE_SYSTEM); this.sendFullGameStateOnReconnect(newSocket, playerEntry, playerIdRole); - if (playerEntry.isTemporarilyDisconnected === false && this.pausedTurnState) { // Если игрок был временно отключен, isTemporarilyDisconnected уже false + // Логика возобновления игры/таймера + if (wasTemporarilyDisconnected && this.pausedTurnState) { this.resumeGameLogicAfterReconnect(playerIdRole); - } else if (playerEntry.isTemporarilyDisconnected === false && !this.pausedTurnState) { - // Игрок не был temp disconnected, и не было сохраненного состояния таймера (значит, он и не останавливался из-за этого игрока) - // Просто отправляем текущее состояние таймера, если он активен - console.log(`[PCH ${this.gameId}] Player was not temp disconnected, and no pausedTurnState. Forcing timer update if active.`); - if (this.gameInstance.turnTimer && this.gameInstance.turnTimer.isActive() && this.gameInstance.turnTimer.onTickCallback) { + } else if (!wasTemporarilyDisconnected) { + // Игрок не был temp disconnected. Таймер на сервере, если шел, то продолжал идти. + // Клиент получил новое состояние. Нужно, чтобы он начал получать обновления таймера. + // Принудительный join выше должен был помочь. + // Дополнительно заставим таймер отправить текущее состояние. + console.log(`[PCH ${this.gameId}] Player was not temp disconnected. Forcing timer update if active (for socket ${newSocket.id}).`); + if (this.gameInstance.turnTimer && this.gameInstance.turnTimer.onTickCallback) { const tt = this.gameInstance.turnTimer; - const elapsedTime = Date.now() - tt.segmentStartTimeMs; - const currentRemaining = Math.max(0, tt.segmentDurationMs - elapsedTime); - tt.onTickCallback(currentRemaining, tt.isConfiguredForPlayerSlotTurn, tt.isManuallyPausedState); - } else if (this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused() && !this.isGameEffectivelyPaused()) { - // Если таймер не активен, не на паузе, и игра не на общей паузе - возможно, его нужно запустить (если сейчас ход этого игрока) - const gs = this.gameInstance.gameState; - if (gs && !gs.isGameOver) { + // Если таймер реально работает (не ход AI и не на ручной паузе от другого игрока) + if (tt.isCurrentlyRunning && !tt.isManuallyPausedState && !tt.isConfiguredForAiMove) { + const elapsedTime = Date.now() - tt.segmentStartTimeMs; + const currentRemaining = Math.max(0, tt.segmentDurationMs - elapsedTime); + console.log(`[PCH ${this.gameId}] Forcing onTickCallback. Remaining: ${currentRemaining}, ForPlayer: ${tt.isConfiguredForPlayerSlotTurn}, ManualPause: ${tt.isManuallyPausedState}`); + tt.onTickCallback(currentRemaining, tt.isConfiguredForPlayerSlotTurn, tt.isManuallyPausedState); + } else if (tt.isConfiguredForAiMove && !tt.isCurrentlyRunning) { // Если ход AI + console.log(`[PCH ${this.gameId}] Forcing onTickCallback for AI move state.`); + tt.onTickCallback(tt.initialTurnDurationMs, tt.isConfiguredForPlayerSlotTurn, false); + } else if (tt.isManuallyPausedState) { // Если на ручной паузе (из-за другого игрока) + console.log(`[PCH ${this.gameId}] Forcing onTickCallback for manually paused state. Remaining: ${tt.segmentDurationMs}`); + tt.onTickCallback(tt.segmentDurationMs, tt.isConfiguredForPlayerSlotTurn, true); + } else if (!tt.isCurrentlyRunning && !tt.isManuallyPausedState && !this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver) { + // Таймер не работает, не на паузе, игра не на паузе - возможно, его нужно запустить + const gs = this.gameInstance.gameState; const isHisTurnNow = (gs.isPlayerTurn && playerIdRole === GAME_CONFIG.PLAYER_ID) || (!gs.isPlayerTurn && playerIdRole === GAME_CONFIG.OPPONENT_ID); const isAiTurnNow = this.mode === 'ai' && !gs.isPlayerTurn; if(isHisTurnNow || isAiTurnNow) { - console.log(`[PCH ${this.gameId}] Timer not active, not paused. Game not paused. Attempting to start timer for ${playerIdRole}. HisTurn: ${isHisTurnNow}, AITurn: ${isAiTurnNow}`); + console.log(`[PCH ${this.gameId}] Timer not active, attempting to start for ${playerIdRole}. HisTurn: ${isHisTurnNow}, AITurn: ${isAiTurnNow}`); this.gameInstance.turnTimer.start(gs.isPlayerTurn, isAiTurnNow); - if (isAiTurnNow && !this.gameInstance.turnTimer.isConfiguredForAiMove && !this.gameInstance.turnTimer.isCurrentlyRunning) { - // Доп. проверка, чтобы AI точно пошел, если это его ход и таймер не стартовал для него как "AI move" + if (isAiTurnNow && !this.gameInstance.turnTimer.getIsConfiguredForAiMove?.()) { setTimeout(() => { if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) { this.gameInstance.processAiTurn(); @@ -322,11 +355,9 @@ class PlayerConnectionHandler { } return true; - } else { // playerEntry не найден - console.warn(`[PCH ${this.gameId}] Попытка переподключения для ${identifier} (Роль ${playerIdRole}), но запись playerEntry не найдена. Это может быть новый игрок или сессия истекла.`); - // Если это новый игрок для этой роли, то addPlayer должен был быть вызван GameManager'ом. - // Если PCH вызывается напрямую, и игрока нет, это ошибка или устаревший запрос. - newSocket.emit('gameError', { message: 'Не удалось восстановить сессию (запись игрока не найдена). Попробуйте создать игру заново.' }); + } else { + console.warn(`[PCH ${this.gameId}] Попытка переподключения для ${identifier} (Роль ${playerIdRole}), но запись playerEntry не найдена.`); + newSocket.emit('gameError', { message: 'Не удалось найти вашу игровую сессию. Попробуйте создать игру заново.' }); return false; } } @@ -335,7 +366,7 @@ class PlayerConnectionHandler { console.log(`[PCH SEND_STATE_RECONNECT] gameId: ${this.gameId}, Role: ${playerIdRole}, Identifier: ${playerEntry.identifier}`); if (!this.gameInstance.gameState) { console.log(`[PCH SEND_STATE_RECONNECT] gameState отсутствует, попытка инициализации...`); - if (!this.gameInstance.initializeGame()) { // initializeGame должен установить gameState + if (!this.gameInstance.initializeGame()) { this.gameInstance._handleCriticalError('reconnect_no_gs_after_init_pch_helper', 'PCH Helper: GS null после повторной инициализации при переподключении.'); return; } @@ -344,33 +375,25 @@ class PlayerConnectionHandler { 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 на основе сохраненных в PCH или данных персонажей if (this.gameInstance.gameState) { if (this.gameInstance.gameState[playerIdRole]) { this.gameInstance.gameState[playerIdRole].name = playerEntry.name || pData?.baseStats?.name || 'Игрок'; } const opponentPCHEntry = Object.values(this.players).find(p => p.id === oppRoleKey); if (this.gameInstance.gameState[oppRoleKey]) { - if (opponentPCHEntry?.name) { - this.gameInstance.gameState[oppRoleKey].name = opponentPCHEntry.name; - } else if (oData?.baseStats?.name) { - this.gameInstance.gameState[oppRoleKey].name = oData.baseStats.name; - } else if (this.mode === 'ai' && oppRoleKey === GAME_CONFIG.OPPONENT_ID) { - this.gameInstance.gameState[oppRoleKey].name = 'Балард'; // Фоллбэк для AI - } else { - this.gameInstance.gameState[oppRoleKey].name = 'Оппонент'; - } + if (opponentPCHEntry?.name) this.gameInstance.gameState[oppRoleKey].name = opponentPCHEntry.name; + else if (oData?.baseStats?.name) this.gameInstance.gameState[oppRoleKey].name = oData.baseStats.name; + else if (this.mode === 'ai' && oppRoleKey === GAME_CONFIG.OPPONENT_ID) this.gameInstance.gameState[oppRoleKey].name = 'Балард'; + else this.gameInstance.gameState[oppRoleKey].name = (this.mode === 'pvp' ? 'Ожидание Оппонента...' : 'Противник'); } } console.log(`[PCH SEND_STATE_RECONNECT] Отправка gameStarted. Player GS: ${this.gameInstance.gameState?.player?.name}, Opponent GS: ${this.gameInstance.gameState?.opponent?.name}. IsPlayerTurn: ${this.gameInstance.gameState?.isPlayerTurn}`); - socket.emit('gameStarted', { // Используем 'gameStarted' для полной синхронизации состояния + socket.emit('gameStarted', { gameId: this.gameId, yourPlayerId: playerIdRole, initialGameState: this.gameInstance.gameState, @@ -378,7 +401,7 @@ class PlayerConnectionHandler { opponentBaseStats: oData?.baseStats || {name: (this.mode === 'pvp' ? 'Ожидание...' : 'Противник AI'), maxHp:1, maxResource:0, resourceName:'N/A', attackPower:0, characterKey: null}, playerAbilities: pData?.abilities, opponentAbilities: oData?.abilities || [], - log: this.gameInstance.consumeLogBuffer(), + log: this.gameInstance.consumeLogBuffer(), // Отправляем все накопленные логи clientConfig: { ...GAME_CONFIG } }); } @@ -396,19 +419,15 @@ class PlayerConnectionHandler { reconnectedPlayerId: reconnectedPlayerIdRole, reconnectedPlayerName: reconnectedName }); - if (this.gameInstance.logBuffer.length > 0) { // Отправляем накопившиеся логи другому игроку + if (this.gameInstance.logBuffer.length > 0) { otherSocket.emit('logUpdate', { log: this.gameInstance.consumeLogBuffer() }); } } - // Обновляем состояние для всех (включая переподключившегося, т.к. его лог мог быть уже потреблен) - this.gameInstance.broadcastGameStateUpdate(); // Это отправит gameState и оставшиеся логи - + this.gameInstance.broadcastGameStateUpdate(); // Обновляем состояние для всех if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver) { - // this.gameInstance.broadcastGameStateUpdate(); // Перенесено выше - - if (Object.keys(this.reconnectTimers).length === 0) { // Только если нет других ожидающих реконнекта + if (Object.keys(this.reconnectTimers).length === 0) { const currentTurnIsForPlayerInGS = this.gameInstance.gameState.isPlayerTurn; const isCurrentTurnAiForTimer = this.mode === 'ai' && !currentTurnIsForPlayerInGS; let resumedFromPausedState = false; @@ -421,20 +440,20 @@ class PlayerConnectionHandler { console.log(`[PCH ${this.gameId}] Возобновляем таймер хода из pausedTurnState. Время: ${this.pausedTurnState.remainingTime}мс. Для игрока (в pausedState): ${this.pausedTurnState.forPlayerRoleIsPlayer}. GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход (в pausedState): ${this.pausedTurnState.isAiCurrentlyMoving}`); this.gameInstance.turnTimer.resume( this.pausedTurnState.remainingTime, - this.pausedTurnState.forPlayerRoleIsPlayer, // Это isConfiguredForPlayerSlotTurn для таймера - this.pausedTurnState.isAiCurrentlyMoving // Это isConfiguredForAiMove для таймера + this.pausedTurnState.forPlayerRoleIsPlayer, + this.pausedTurnState.isAiCurrentlyMoving ); resumedFromPausedState = true; } else { console.warn(`[PCH ${this.gameId}] pausedTurnState (${JSON.stringify(this.pausedTurnState)}) не совпадает с текущим ходом в gameState (isPlayerTurn: ${currentTurnIsForPlayerInGS}). Сбрасываем pausedTurnState и запускаем таймер заново, если нужно.`); } - this.pausedTurnState = null; // Сбрасываем в любом случае + this.pausedTurnState = null; } if (!resumedFromPausedState && this.gameInstance.turnTimer && !this.gameInstance.turnTimer.isActive() && !this.gameInstance.turnTimer.isPaused()) { - console.log(`[PCH ${this.gameId}] Запускаем таймер хода заново после реконнекта (pausedState не использовался или был неактуален, таймер неактивен и не на паузе). GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход для таймера: ${isCurrentTurnAiForTimer}`); + console.log(`[PCH ${this.gameId}] Запускаем таймер хода заново после реконнекта (pausedState не использовался/неактуален, таймер неактивен и не на паузе). GS ход игрока: ${currentTurnIsForPlayerInGS}. AI ход для таймера: ${isCurrentTurnAiForTimer}`); this.gameInstance.turnTimer.start(currentTurnIsForPlayerInGS, isCurrentTurnAiForTimer); - if (isCurrentTurnAiForTimer && !this.gameInstance.turnTimer.isConfiguredForAiMove && !this.gameInstance.turnTimer.isCurrentlyRunning) { + if (isCurrentTurnAiForTimer && !this.gameInstance.turnTimer.getIsConfiguredForAiMove?.()) { setTimeout(() => { if (!this.isGameEffectivelyPaused() && this.gameInstance.gameState && !this.gameInstance.gameState.isGameOver && this.mode === 'ai' && !this.gameInstance.gameState.isPlayerTurn) { this.gameInstance.processAiTurn(); @@ -454,13 +473,15 @@ class PlayerConnectionHandler { clearReconnectTimer(playerIdRole) { if (this.reconnectTimers[playerIdRole]) { - clearTimeout(this.reconnectTimers[playerIdRole].timerId); - this.reconnectTimers[playerIdRole].timerId = null; // Явно обнуляем + if (this.reconnectTimers[playerIdRole].timerId) { + clearTimeout(this.reconnectTimers[playerIdRole].timerId); + this.reconnectTimers[playerIdRole].timerId = null; + } if (this.reconnectTimers[playerIdRole].updateIntervalId) { clearInterval(this.reconnectTimers[playerIdRole].updateIntervalId); - this.reconnectTimers[playerIdRole].updateIntervalId = null; // Явно обнуляем + this.reconnectTimers[playerIdRole].updateIntervalId = null; } - delete this.reconnectTimers[playerIdRole]; // Удаляем всю запись + delete this.reconnectTimers[playerIdRole]; console.log(`[PCH ${this.gameId}] Очищен таймер переподключения для роли ${playerIdRole}.`); } } @@ -477,14 +498,13 @@ class PlayerConnectionHandler { 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') { const humanPlayer = Object.values(this.players).find(p => p.id === GAME_CONFIG.PLAYER_ID); - return humanPlayer?.isTemporarilyDisconnected ?? false; // Если игрока нет, не на паузе. Если есть - зависит от его состояния. + return humanPlayer?.isTemporarilyDisconnected ?? false; } return false; } diff --git a/server/game/instance/TurnTimer.js b/server/game/instance/TurnTimer.js index 2274458..da29409 100644 --- a/server/game/instance/TurnTimer.js +++ b/server/game/instance/TurnTimer.js @@ -1,25 +1,33 @@ // /server/game/instance/TurnTimer.js class TurnTimer { + /** + * Конструктор таймера хода. + * @param {number} turnDurationMs - Изначальная длительность хода в миллисекундах. + * @param {number} updateIntervalMs - Интервал для отправки обновлений времени клиентам (в мс). + * @param {function} onTimeoutCallback - Колбэк, вызываемый при истечении времени хода. + * @param {function} onTickCallback - Колбэк, вызываемый на каждом тике обновления (передает remainingTimeMs, isForPlayerSlotTurn_timerPerspective, isTimerEffectivelyPaused_byLogic). + * @param {string} [gameIdForLogs=''] - (Опционально) ID игры для более понятных логов таймера. + */ constructor(turnDurationMs, updateIntervalMs, onTimeoutCallback, onTickCallback, gameIdForLogs = '') { this.initialTurnDurationMs = turnDurationMs; this.updateIntervalMs = updateIntervalMs; this.onTimeoutCallback = onTimeoutCallback; - this.onTickCallback = onTickCallback; // (remainingTimeMs, isForPlayerSlotTurn_timerPerspective, isTimerEffectivelyPaused_byLogic) + this.onTickCallback = onTickCallback; this.gameId = gameIdForLogs; - this.timeoutId = null; - this.tickIntervalId = null; + this.timeoutId = null; // ID для setTimeout (обработка общего таймаута хода) + this.tickIntervalId = null; // ID для setInterval (периодическое обновление клиента) - this.segmentStartTimeMs = 0; // Время начала текущего активного сегмента (после start/resume) - this.segmentDurationMs = 0; // Длительность, с которой был запущен текущий сегмент + this.segmentStartTimeMs = 0; // Время (Date.now()) начала текущего активного сегмента (после start/resume) + this.segmentDurationMs = 0; // Длительность, с которой был запущен текущий активный сегмент - this.isCurrentlyRunning = false; // Идет ли активный отсчет (не на паузе, не ход AI) - this.isManuallyPausedState = false; // Была ли вызвана pause() + this.isCurrentlyRunning = false; // Идет ли активный отсчет (не на паузе из-за дисконнекта, не ход AI) + this.isManuallyPausedState = false; // Была ли вызвана pause() (например, из-за дисконнекта игрока) - // Состояние, для которого таймер был запущен (или должен быть запущен) - this.isConfiguredForPlayerSlotTurn = false; - this.isConfiguredForAiMove = false; + // Состояние, для которого таймер был сконфигурирован при последнем запуске/возобновлении + this.isConfiguredForPlayerSlotTurn = false; // true, если таймер отсчитывает ход игрока (слот 'player') + this.isConfiguredForAiMove = false; // true, если это ход AI (таймер для реального игрока не тикает) console.log(`[TurnTimer ${this.gameId}] Initialized. Duration: ${this.initialTurnDurationMs}ms, Interval: ${this.updateIntervalMs}ms`); } @@ -28,10 +36,12 @@ class TurnTimer { if (this.timeoutId) { clearTimeout(this.timeoutId); this.timeoutId = null; + // console.log(`[TurnTimer ${this.gameId}] Cleared timeoutId.`); } if (this.tickIntervalId) { clearInterval(this.tickIntervalId); this.tickIntervalId = null; + // console.log(`[TurnTimer ${this.gameId}] Cleared tickIntervalId.`); } } @@ -39,47 +49,62 @@ class TurnTimer { * Запускает или перезапускает таймер хода. * @param {boolean} isPlayerSlotTurn - true, если сейчас ход слота 'player'. * @param {boolean} isAiMakingMove - true, если текущий ход делает AI. - * @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого времени. + * @param {number|null} [customRemainingTimeMs=null] - Если передано, таймер начнется с этого времени (обычно при resume). */ start(isPlayerSlotTurn, isAiMakingMove = false, customRemainingTimeMs = null) { - console.log(`[TurnTimer ${this.gameId}] Attempting START. ForPlayer: ${isPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CustomTime: ${customRemainingTimeMs}, ManualPause: ${this.isManuallyPausedState}`); + console.log(`[TurnTimer ${this.gameId}] Attempting START. ForPlayer: ${isPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CustomTime: ${customRemainingTimeMs}, CurrentManualPauseState: ${this.isManuallyPausedState}`); this._clearInternalTimers(); // Всегда очищаем старые таймеры перед новым запуском this.isConfiguredForPlayerSlotTurn = isPlayerSlotTurn; this.isConfiguredForAiMove = isAiMakingMove; - // Если это не resume (т.е. customRemainingTimeMs не передан явно как результат pause), - // то сбрасываем флаг ручной паузы. + // Если start вызывается НЕ из resume (т.е. customRemainingTimeMs не передан как результат pause), + // то флаг ручной паузы должен быть сброшен. + // Если это вызов из resume, isManuallyPausedState уже был сброшен в resume перед вызовом start. if (customRemainingTimeMs === null) { this.isManuallyPausedState = false; } if (this.isConfiguredForAiMove) { - this.isCurrentlyRunning = false; // Для хода AI основной таймер не "бежит" для игрока - console.log(`[TurnTimer ${this.gameId}] START: AI's turn. Player timer not actively ticking.`); + this.isCurrentlyRunning = false; // Для хода AI основной таймер не "бежит" для UI игрока + this.segmentDurationMs = this.initialTurnDurationMs; // Для AI показываем полную длительность (или сколько он думает) + this.segmentStartTimeMs = Date.now(); // На всякий случай, хотя не используется для тиков AI + console.log(`[TurnTimer ${this.gameId}] START: AI's turn. Player timer not actively ticking. ManualPause: ${this.isManuallyPausedState}`); if (this.onTickCallback) { - // Отправляем состояние "ход AI", таймер не тикает для игрока, не на ручной паузе + // Отправляем состояние "ход AI", таймер не тикает для игрока, не на ручной паузе (т.к. игра идет) this.onTickCallback(this.initialTurnDurationMs, this.isConfiguredForPlayerSlotTurn, false); } return; } // Если это не ход AI, то таймер должен работать для игрока (или оппонента-человека) - this.segmentDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs > 0) + this.segmentDurationMs = (typeof customRemainingTimeMs === 'number' && customRemainingTimeMs >= 0) // Допускаем 0 для немедленного таймаута ? customRemainingTimeMs : this.initialTurnDurationMs; this.segmentStartTimeMs = Date.now(); this.isCurrentlyRunning = true; // Таймер теперь активен - // this.isManuallyPausedState остается как есть, если это был resume, или false, если это новый start console.log(`[TurnTimer ${this.gameId}] STARTED. Effective Duration: ${this.segmentDurationMs}ms. ForPlayer: ${this.isConfiguredForPlayerSlotTurn}. IsRunning: ${this.isCurrentlyRunning}. ManualPause: ${this.isManuallyPausedState}`); + if (this.segmentDurationMs <= 0) { // Если время 0 или меньше, сразу таймаут + console.log(`[TurnTimer ${this.gameId}] Start with 0 or less time, calling timeout immediately.`); + if (this.onTimeoutCallback) { + this.onTimeoutCallback(); + } + this._clearInternalTimers(); + this.isCurrentlyRunning = false; + // Отправляем финальный тик с 0 временем + if (this.onTickCallback) { + this.onTickCallback(0, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState); + } + return; + } + this.timeoutId = setTimeout(() => { console.log(`[TurnTimer ${this.gameId}] Main TIMEOUT occurred. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`); - // Проверяем, что таймер все еще должен был работать и не был на паузе if (this.isCurrentlyRunning && !this.isManuallyPausedState) { - this._clearInternalTimers(); // Очищаем все, включая интервал + this._clearInternalTimers(); this.isCurrentlyRunning = false; if (this.onTimeoutCallback) { this.onTimeoutCallback(); @@ -90,10 +115,17 @@ class TurnTimer { }, this.segmentDurationMs); this.tickIntervalId = setInterval(() => { - // Таймер должен обновлять UI только если он isCurrentlyRunning и НЕ isManuallyPausedState - // isManuallyPausedState проверяется в onTickCallback, который должен передать "isPaused" клиенту - if (!this.isCurrentlyRunning) { // Если таймер был остановлен (clear/timeout) - this._clearInternalTimers(); // Убедимся, что этот интервал тоже остановлен + if (!this.isCurrentlyRunning || this.isManuallyPausedState) { + // Если таймер остановлен или на ручной паузе, интервал не должен ничего делать, кроме как, возможно, + // сообщить, что он на паузе. Но лучше, чтобы onTickCallback вызывался с флагом паузы. + // Если он был остановлен (isCurrentlyRunning=false, но не isManuallyPausedState), + // то clear() должен был уже остановить и этот интервал. + // Эта проверка - дополнительная защита. + // console.log(`[TurnTimer ${this.gameId}] Tick interval fired but timer not running or manually paused. Running: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`); + if (!this.isCurrentlyRunning && this.tickIntervalId) { // Если совсем остановлен, чистим себя + clearInterval(this.tickIntervalId); + this.tickIntervalId = null; + } return; } @@ -101,13 +133,16 @@ class TurnTimer { const remainingTime = Math.max(0, this.segmentDurationMs - elapsedTime); if (this.onTickCallback) { - // Передаем isManuallyPausedState как состояние "паузы" для клиента + // Передаем isManuallyPausedState как состояние "паузы" для клиента, + // но здесь оно всегда false, т.к. есть проверка `!this.isManuallyPausedState` выше. + // Более корректно передавать `this.isManuallyPausedState || !this.isCurrentlyRunning` как общую паузу с точки зрения таймера. + // Но PCH передает `isPaused || this.isGameEffectivelyPaused()`. + // Для `onTickCallback` здесь, isPaused будет отражать `this.isManuallyPausedState`. this.onTickCallback(remainingTime, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState); } - - // Не очищаем интервал здесь при remainingTime <= 0, пусть setTimeout это сделает. - // Отправка 0 - это нормально. }, this.updateIntervalMs); + console.log(`[TurnTimer ${this.gameId}] Tick interval started: ${this.tickIntervalId}.`); + // Немедленная первая отправка состояния таймера if (this.onTickCallback) { @@ -117,121 +152,123 @@ class TurnTimer { } pause() { - console.log(`[TurnTimer ${this.gameId}] Attempting PAUSE. IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}, ManualPause: ${this.isManuallyPausedState}`); + console.log(`[TurnTimer ${this.gameId}] Attempting PAUSE. IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}, CurrentManualPauseState: ${this.isManuallyPausedState}`); - if (this.isManuallyPausedState) { // Уже на ручной паузе - console.log(`[TurnTimer ${this.gameId}] PAUSE called, but already manually paused. Returning previous pause state.`); - // Нужно вернуть актуальное оставшееся время, которое было на момент установки паузы. - // segmentDurationMs при паузе сохраняет это значение. - if (this.onTickCallback) { // Уведомляем клиента еще раз, что на паузе + if (this.isManuallyPausedState) { + console.log(`[TurnTimer ${this.gameId}] PAUSE called, but already manually paused. Current saved duration (remaining): ${this.segmentDurationMs}`); + if (this.onTickCallback) { this.onTickCallback(this.segmentDurationMs, this.isConfiguredForPlayerSlotTurn, true); } return { - remainingTime: this.segmentDurationMs, // Это время, которое осталось на момент паузы + remainingTime: this.segmentDurationMs, forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, - isAiCurrentlyMoving: this.isConfiguredForAiMove // Важно сохранить, чей ход это был + isAiCurrentlyMoving: this.isConfiguredForAiMove }; } - let remainingTimeToSave; + let remainingTimeToSaveOnPause; if (this.isConfiguredForAiMove) { - // Если ход AI, таймер для игрока не тикал, у него полное время - remainingTimeToSave = this.initialTurnDurationMs; - console.log(`[TurnTimer ${this.gameId}] PAUSED during AI move. Effective remaining: ${remainingTimeToSave}ms for player turn.`); + remainingTimeToSaveOnPause = this.initialTurnDurationMs; // Для AI всегда полное время (или как настроено) + console.log(`[TurnTimer ${this.gameId}] PAUSED during AI move. Effective remaining for player: ${remainingTimeToSaveOnPause}ms.`); } else if (this.isCurrentlyRunning) { - // Таймер активно работал для игрока/оппонента-человека const elapsedTime = Date.now() - this.segmentStartTimeMs; - remainingTimeToSave = Math.max(0, this.segmentDurationMs - elapsedTime); - console.log(`[TurnTimer ${this.gameId}] PAUSED while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTimeToSave}ms from segment duration ${this.segmentDurationMs}ms.`); + remainingTimeToSaveOnPause = Math.max(0, this.segmentDurationMs - elapsedTime); + console.log(`[TurnTimer ${this.gameId}] PAUSED while running. Elapsed: ${elapsedTime}ms, Remaining: ${remainingTimeToSaveOnPause}ms from segment duration ${this.segmentDurationMs}ms.`); } else { - // Таймер не был активен (например, уже истек, был очищен, или это был start() для AI) - // В этом случае, если не ход AI, то время 0 - remainingTimeToSave = 0; + // Таймер не был активен (и не ход AI). Значит, время 0. + remainingTimeToSaveOnPause = 0; console.log(`[TurnTimer ${this.gameId}] PAUSE called, but timer not actively running (and not AI move). Remaining set to 0.`); } this._clearInternalTimers(); - this.isCurrentlyRunning = false; - this.isManuallyPausedState = true; - this.segmentDurationMs = remainingTimeToSave; // Сохраняем оставшееся время для resume + this.isCurrentlyRunning = false; // Отсчет остановлен + this.isManuallyPausedState = true; // Устанавливаем флаг ручной паузы + this.segmentDurationMs = remainingTimeToSaveOnPause; // Сохраняем оставшееся время в segmentDurationMs для resume if (this.onTickCallback) { - console.log(`[TurnTimer ${this.gameId}] Notifying client of PAUSE. Remaining: ${remainingTimeToSave}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}`); - this.onTickCallback(remainingTimeToSave, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true + console.log(`[TurnTimer ${this.gameId}] Notifying client of PAUSE state. Remaining: ${remainingTimeToSaveOnPause}, ForPlayer: ${this.isConfiguredForPlayerSlotTurn}`); + this.onTickCallback(remainingTimeToSaveOnPause, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true } return { - remainingTime: remainingTimeToSave, - forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, // Чей ход это был - isAiCurrentlyMoving: this.isConfiguredForAiMove // Был ли это ход AI + remainingTime: remainingTimeToSaveOnPause, + forPlayerRoleIsPlayer: this.isConfiguredForPlayerSlotTurn, + isAiCurrentlyMoving: this.isConfiguredForAiMove }; } - resume(remainingTimeMs, forPlayerSlotTurn, isAiMakingMove) { - console.log(`[TurnTimer ${this.gameId}] Attempting RESUME. SavedRemaining: ${remainingTimeMs}, ForPlayer: ${forPlayerSlotTurn}, IsAI: ${isAiMakingMove}, ManualPauseBefore: ${this.isManuallyPausedState}`); + resume(remainingTimeMsFromPause, forPlayerSlotTurn, isAiMakingMove) { + console.log(`[TurnTimer ${this.gameId}] Attempting RESUME. TimeFromPause: ${remainingTimeMsFromPause}, ForPlayer: ${forPlayerSlotTurn}, IsAI: ${isAiMakingMove}, CurrentManualPauseState: ${this.isManuallyPausedState}`); if (!this.isManuallyPausedState) { - console.warn(`[TurnTimer ${this.gameId}] RESUME called, but timer was not manually paused. Current state - IsRunning: ${this.isCurrentlyRunning}, IsAI: ${this.isConfiguredForAiMove}. Ignoring resume, let PCH handle start if needed.`); - // Если не был на ручной паузе, возможно, игра уже продолжается или была очищена. - // Не вызываем start() отсюда, чтобы избежать неожиданного поведения. - // PCH должен решить, нужен ли новый start(). - // Однако, если текущий ход совпадает, и таймер просто неактивен, можно запустить. - // Но лучше, чтобы PCH всегда вызывал start() с нуля, если resume не применим. - // Просто отправим текущее состояние, если onTickCallback есть. - if (this.onTickCallback) { - const currentElapsedTime = this.isCurrentlyRunning ? (Date.now() - this.segmentStartTimeMs) : 0; - const currentRemaining = this.isCurrentlyRunning ? Math.max(0, this.segmentDurationMs - currentElapsedTime) : this.segmentDurationMs; - this.onTickCallback(currentRemaining, this.isConfiguredForPlayerSlotTurn, this.isManuallyPausedState); + console.warn(`[TurnTimer ${this.gameId}] RESUME called, but timer was not manually paused. This might indicate a logic issue elsewhere or a stale resume attempt. Ignoring.`); + // Если таймер не был на ручной паузе, то он либо работает, либо уже остановлен по другой причине. + // Не вызываем start() отсюда, чтобы PCH мог принять решение о новом старте, если это необходимо. + // Можно отправить текущее состояние, если он работает, для синхронизации. + if (this.isCurrentlyRunning && this.onTickCallback) { + const elapsedTime = Date.now() - this.segmentStartTimeMs; + const currentRemaining = Math.max(0, this.segmentDurationMs - elapsedTime); + console.log(`[TurnTimer ${this.gameId}] Resume ignored (not manually paused), sending current state if running. Remaining: ${currentRemaining}`); + this.onTickCallback(currentRemaining, this.isConfiguredForPlayerSlotTurn, false); } return; } - if (remainingTimeMs <= 0 && !isAiMakingMove) { // Если не ход AI и время вышло + // Сбрасываем флаг ручной паузы ПЕРЕД вызовом start + this.isManuallyPausedState = false; + + if (remainingTimeMsFromPause <= 0 && !isAiMakingMove) { console.log(`[TurnTimer ${this.gameId}] RESUME called with 0 or less time (and not AI move). Triggering timeout.`); - this.isManuallyPausedState = false; // Сбрасываем флаг - this._clearInternalTimers(); // Убедимся, что все остановлено + this._clearInternalTimers(); this.isCurrentlyRunning = false; if (this.onTimeoutCallback) { this.onTimeoutCallback(); } + // Отправляем финальный тик с 0 временем и снятой паузой + if (this.onTickCallback) { + this.onTickCallback(0, forPlayerSlotTurn, false); + } return; } - // Сбрасываем флаг ручной паузы и запускаем таймер с сохраненным состоянием - this.isManuallyPausedState = false; - this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMs); // `start` теперь правильно обработает customRemainingTimeMs + // Запускаем таймер с сохраненным состоянием и оставшимся временем + // `start` сама установит isCurrentlyRunning и другие флаги. + this.start(forPlayerSlotTurn, isAiMakingMove, remainingTimeMsFromPause); } + /** + * Очищает (останавливает) все активные таймеры и сбрасывает состояние. + * Вызывается при завершении действия, таймауте, или если игра заканчивается. + */ clear() { console.log(`[TurnTimer ${this.gameId}] CLEAR called. WasRunning: ${this.isCurrentlyRunning}, ManualPause: ${this.isManuallyPausedState}`); this._clearInternalTimers(); this.isCurrentlyRunning = false; - // При полном clear сбрасываем и ручную паузу, т.к. таймер полностью останавливается. - // `pause` использует этот метод, но затем сам выставляет isManuallyPausedState = true. - this.isManuallyPausedState = false; - this.segmentDurationMs = 0; // Сбрасываем сохраненную длительность - this.segmentStartTimeMs = 0; + this.isManuallyPausedState = false; // Полная очистка сбрасывает и ручную паузу + // this.segmentDurationMs = 0; // Можно сбросить, но start() все равно установит новое + // this.segmentStartTimeMs = 0; - // Опционально: уведомить клиента, что таймер остановлен (например, null или 0) - // if (this.onTickCallback) { - // this.onTickCallback(null, this.isConfiguredForPlayerSlotTurn, true); // isPaused = true (т.к. он остановлен) - // } + // При clear не отправляем tickCallback, т.к. это означает конец отсчета для текущего хода. + // Клиентский UI должен будет обновиться следующим gameStateUpdate или gameStarted. } isActive() { - // Таймер активен, если он isCurrentlyRunning и не на ручной паузе - return this.isCurrentlyRunning && !this.isManuallyPausedState; + // Активен, если запущен И не на ручной паузе И не ход AI (для которого таймер игрока не тикает) + return this.isCurrentlyRunning && !this.isManuallyPausedState && !this.isConfiguredForAiMove; } - isPaused() { // Возвращает, находится ли таймер в состоянии ручной паузы + isPaused() { + // Возвращает, находится ли таймер в состоянии ручной паузы (вызванной извне) return this.isManuallyPausedState; } - // Этот геттер больше не нужен в таком виде, т.к. isConfiguredForAiMove хранит это состояние - // get isAiCurrentlyMakingMove() { - // return this.isConfiguredForAiMove && !this.isCurrentlyRunning; - // } + // Геттер для PCH, чтобы знать, сконфигурирован ли таймер для хода AI. + // Это не означает, что AI *прямо сейчас* делает вычисления, а лишь то, + // что таймер был запущен для состояния "ход AI". + getIsConfiguredForAiMove() { + return this.isConfiguredForAiMove; + } } module.exports = TurnTimer; \ No newline at end of file